Skip to main content

relay_knowledge/env/
mod.rs

1//! Environment variable boundary for runtime configuration.
2//!
3//! This module is the only production code that reads process environment
4//! variables. It normalizes platform directory inputs and relay-specific
5//! overrides into typed structures before application, path, or network code
6//! consumes them.
7
8use std::{
9    collections::HashMap,
10    env as process_env,
11    error::Error,
12    ffi::{OsStr, OsString},
13    fmt,
14    path::PathBuf,
15};
16
17pub const RELAY_KNOWLEDGE_HOME: &str = "RELAY_KNOWLEDGE_HOME";
18pub const RELAY_KNOWLEDGE_CONFIG_DIR: &str = "RELAY_KNOWLEDGE_CONFIG_DIR";
19pub const RELAY_KNOWLEDGE_DATA_DIR: &str = "RELAY_KNOWLEDGE_DATA_DIR";
20pub const RELAY_KNOWLEDGE_STATE_DIR: &str = "RELAY_KNOWLEDGE_STATE_DIR";
21pub const RELAY_KNOWLEDGE_CACHE_DIR: &str = "RELAY_KNOWLEDGE_CACHE_DIR";
22pub const RELAY_KNOWLEDGE_LOG_DIR: &str = "RELAY_KNOWLEDGE_LOG_DIR";
23pub const RELAY_KNOWLEDGE_TEMP_DIR: &str = "RELAY_KNOWLEDGE_TEMP_DIR";
24pub const RELAY_KNOWLEDGE_RUNTIME_DIR: &str = "RELAY_KNOWLEDGE_RUNTIME_DIR";
25pub const RELAY_KNOWLEDGE_SERVICE_DIR: &str = "RELAY_KNOWLEDGE_SERVICE_DIR";
26pub const RELAY_KNOWLEDGE_HTTP_BIND: &str = "RELAY_KNOWLEDGE_HTTP_BIND";
27pub const RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS";
28pub const RELAY_KNOWLEDGE_HTTP_SHUTDOWN_TIMEOUT_MS: &str =
29    "RELAY_KNOWLEDGE_HTTP_SHUTDOWN_TIMEOUT_MS";
30pub const RELAY_KNOWLEDGE_HTTP_MAX_BODY_BYTES: &str = "RELAY_KNOWLEDGE_HTTP_MAX_BODY_BYTES";
31pub const RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS: &str = "RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS";
32pub const RELAY_KNOWLEDGE_QOS_MAX_IN_FLIGHT_REQUESTS: &str =
33    "RELAY_KNOWLEDGE_QOS_MAX_IN_FLIGHT_REQUESTS";
34pub const RELAY_KNOWLEDGE_QOS_MAX_QUEUE_DEPTH: &str = "RELAY_KNOWLEDGE_QOS_MAX_QUEUE_DEPTH";
35pub const RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED: &str =
36    "RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED";
37pub const RELAY_KNOWLEDGE_MCP_ENDPOINT: &str = "RELAY_KNOWLEDGE_MCP_ENDPOINT";
38pub const RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS: &str = "RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS";
39pub const RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES: &str = "RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES";
40pub const RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE: &str =
41    "RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE";
42pub const RELAY_KNOWLEDGE_MCP_MAX_LIMIT: &str = "RELAY_KNOWLEDGE_MCP_MAX_LIMIT";
43pub const RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES: &str = "RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES";
44pub const RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS: &str =
45    "RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS";
46pub const RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED: &str =
47    "RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED";
48pub const RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH: &str = "RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH";
49pub const RELAY_KNOWLEDGE_SEMANTIC_BACKEND: &str = "RELAY_KNOWLEDGE_SEMANTIC_BACKEND";
50pub const RELAY_KNOWLEDGE_VECTOR_BACKEND: &str = "RELAY_KNOWLEDGE_VECTOR_BACKEND";
51pub const RELAY_KNOWLEDGE_LLM_PROVIDER: &str = "RELAY_KNOWLEDGE_LLM_PROVIDER";
52pub const RELAY_KNOWLEDGE_EMBEDDING_BASE_URL: &str = "RELAY_KNOWLEDGE_EMBEDDING_BASE_URL";
53pub const RELAY_KNOWLEDGE_EMBEDDING_API_KEY: &str = "RELAY_KNOWLEDGE_EMBEDDING_API_KEY";
54pub const RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL: &str = "RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL";
55pub const RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL: &str = "RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL";
56pub const RELAY_KNOWLEDGE_EMBEDDING_DIMENSION: &str = "RELAY_KNOWLEDGE_EMBEDDING_DIMENSION";
57pub const RELAY_KNOWLEDGE_EMBEDDING_BATCH_SIZE: &str = "RELAY_KNOWLEDGE_EMBEDDING_BATCH_SIZE";
58pub const RELAY_KNOWLEDGE_EMBEDDING_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_EMBEDDING_TIMEOUT_MS";
59pub const RELAY_KNOWLEDGE_EMBEDDING_MAX_CONCURRENCY: &str =
60    "RELAY_KNOWLEDGE_EMBEDDING_MAX_CONCURRENCY";
61pub const RELAY_KNOWLEDGE_RERANK_BACKEND: &str = "RELAY_KNOWLEDGE_RERANK_BACKEND";
62pub const RELAY_KNOWLEDGE_RERANK_MODEL: &str = "RELAY_KNOWLEDGE_RERANK_MODEL";
63pub const RELAY_KNOWLEDGE_RERANK_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_RERANK_TIMEOUT_MS";
64pub const RELAY_KNOWLEDGE_RERANK_CANDIDATE_MULTIPLIER: &str =
65    "RELAY_KNOWLEDGE_RERANK_CANDIDATE_MULTIPLIER";
66pub const RELAY_KNOWLEDGE_RERANK_MAX_CANDIDATES: &str = "RELAY_KNOWLEDGE_RERANK_MAX_CANDIDATES";
67pub const RELAY_KNOWLEDGE_WORKER_EMBEDDING_ENDPOINT: &str =
68    "RELAY_KNOWLEDGE_WORKER_EMBEDDING_ENDPOINT";
69pub const RELAY_KNOWLEDGE_WORKER_OCR_ENDPOINT: &str = "RELAY_KNOWLEDGE_WORKER_OCR_ENDPOINT";
70pub const RELAY_KNOWLEDGE_WORKER_VISION_ENDPOINT: &str = "RELAY_KNOWLEDGE_WORKER_VISION_ENDPOINT";
71pub const RELAY_KNOWLEDGE_WORKER_EXTRACTOR_ENDPOINT: &str =
72    "RELAY_KNOWLEDGE_WORKER_EXTRACTOR_ENDPOINT";
73pub const RELAY_KNOWLEDGE_WORKER_MAX_IN_FLIGHT: &str = "RELAY_KNOWLEDGE_WORKER_MAX_IN_FLIGHT";
74pub const RELAY_KNOWLEDGE_SILENT_UPDATES_ENABLED: &str = "RELAY_KNOWLEDGE_SILENT_UPDATES_ENABLED";
75pub const RELAY_KNOWLEDGE_FILE_INDEX_ENABLED: &str = "RELAY_KNOWLEDGE_FILE_INDEX_ENABLED";
76pub const RELAY_KNOWLEDGE_FILE_INDEX_ROOTS: &str = "RELAY_KNOWLEDGE_FILE_INDEX_ROOTS";
77pub const RELAY_KNOWLEDGE_FILE_INDEX_EXCLUDES: &str = "RELAY_KNOWLEDGE_FILE_INDEX_EXCLUDES";
78pub const RELAY_KNOWLEDGE_FILE_INDEX_MAX_DEPTH: &str = "RELAY_KNOWLEDGE_FILE_INDEX_MAX_DEPTH";
79pub const RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILE_BYTES: &str =
80    "RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILE_BYTES";
81pub const RELAY_KNOWLEDGE_FILE_INDEX_SCAN_INTERVAL_MS: &str =
82    "RELAY_KNOWLEDGE_FILE_INDEX_SCAN_INTERVAL_MS";
83pub const RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS: &str =
84    "RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS";
85pub const RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILES_PER_ROOT: &str =
86    "RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILES_PER_ROOT";
87pub const RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS: &str = "RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS";
88pub const RELAY_KNOWLEDGE_UPDATE_CHECK_ENABLED: &str = "RELAY_KNOWLEDGE_UPDATE_CHECK_ENABLED";
89pub const RELAY_KNOWLEDGE_UPDATE_SOURCES: &str = "RELAY_KNOWLEDGE_UPDATE_SOURCES";
90pub const RELAY_KNOWLEDGE_UPDATE_CHECK_INTERVAL_MS: &str =
91    "RELAY_KNOWLEDGE_UPDATE_CHECK_INTERVAL_MS";
92pub const RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO: &str = "RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO";
93pub const RELAY_OTEL_ENDPOINT: &str = "RELAY_OTEL_ENDPOINT";
94pub const RELAY_OTEL_TRACES: &str = "RELAY_OTEL_TRACES";
95pub const RELAY_OTEL_METRICS: &str = "RELAY_OTEL_METRICS";
96pub const RELAY_OTEL_EXPORT_TIMEOUT_MS: &str = "RELAY_OTEL_EXPORT_TIMEOUT_MS";
97pub const RELAY_OTEL_SERVICE_ENVIRONMENT: &str = "RELAY_OTEL_SERVICE_ENVIRONMENT";
98pub const HTTPS_PROXY: &str = "HTTPS_PROXY";
99pub const HTTPS_PROXY_LOWER: &str = "https_proxy";
100pub const HTTP_PROXY: &str = "HTTP_PROXY";
101pub const HTTP_PROXY_LOWER: &str = "http_proxy";
102pub const ALL_PROXY: &str = "ALL_PROXY";
103pub const ALL_PROXY_LOWER: &str = "all_proxy";
104pub const NO_PROXY: &str = "NO_PROXY";
105pub const NO_PROXY_LOWER: &str = "no_proxy";
106pub const SSL_VERIFY: &str = "SSL_VERIFY";
107pub const SSL_VERIFY_LOWER: &str = "ssl_verify";
108
109const HOME: &str = "HOME";
110const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME";
111const XDG_DATA_HOME: &str = "XDG_DATA_HOME";
112const XDG_STATE_HOME: &str = "XDG_STATE_HOME";
113const XDG_CACHE_HOME: &str = "XDG_CACHE_HOME";
114const XDG_RUNTIME_DIR: &str = "XDG_RUNTIME_DIR";
115const APPDATA: &str = "APPDATA";
116const LOCALAPPDATA: &str = "LOCALAPPDATA";
117const TMPDIR: &str = "TMPDIR";
118const TEMP: &str = "TEMP";
119const TMP: &str = "TMP";
120
121/// Operating-system family used by path resolution.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum PlatformKind {
124    Unix,
125    Macos,
126    Windows,
127    Other,
128}
129
130impl PlatformKind {
131    /// Detects the current target platform without consulting environment state.
132    pub const fn current() -> Self {
133        if cfg!(target_os = "windows") {
134            Self::Windows
135        } else if cfg!(target_os = "macos") {
136            Self::Macos
137        } else if cfg!(unix) {
138            Self::Unix
139        } else {
140            Self::Other
141        }
142    }
143}
144
145/// Platform directory inputs captured from the environment.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct PlatformEnvironment {
148    pub platform: PlatformKind,
149    pub home_dir: Option<PathBuf>,
150    pub xdg_config_home: Option<PathBuf>,
151    pub xdg_data_home: Option<PathBuf>,
152    pub xdg_state_home: Option<PathBuf>,
153    pub xdg_cache_home: Option<PathBuf>,
154    pub xdg_runtime_dir: Option<PathBuf>,
155    pub app_data: Option<PathBuf>,
156    pub local_app_data: Option<PathBuf>,
157    pub temp_dir: Option<PathBuf>,
158}
159
160/// Relay-specific path overrides read from environment variables.
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
162pub struct PathEnvOverrides {
163    pub home: Option<PathBuf>,
164    pub config_dir: Option<PathBuf>,
165    pub data_dir: Option<PathBuf>,
166    pub state_dir: Option<PathBuf>,
167    pub cache_dir: Option<PathBuf>,
168    pub log_dir: Option<PathBuf>,
169    pub temp_dir: Option<PathBuf>,
170    pub runtime_dir: Option<PathBuf>,
171    pub service_dir: Option<PathBuf>,
172}
173
174/// Network settings read from relay-specific and generic environment variables.
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
176pub struct NetworkEnvOverrides {
177    pub http_bind: Option<String>,
178    pub http_request_timeout_ms: Option<u64>,
179    pub http_shutdown_timeout_ms: Option<u64>,
180    pub http_max_body_bytes: Option<u64>,
181    pub proxy: Option<String>,
182    pub no_proxy: Option<String>,
183    pub ssl_verify: Option<bool>,
184    pub qos_max_connections: Option<usize>,
185    pub qos_max_in_flight_requests: Option<usize>,
186    pub qos_max_queue_depth: Option<usize>,
187}
188
189/// Agent protocol settings read from relay-specific environment variables.
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct AgentEnvOverrides {
192    pub mcp_streamable_http_enabled: Option<bool>,
193    pub mcp_endpoint: Option<String>,
194    pub mcp_allowed_origins: Option<String>,
195    pub mcp_allowed_scopes: Option<String>,
196    pub mcp_allow_unspecified_scope: Option<bool>,
197    pub mcp_max_limit: Option<usize>,
198    pub mcp_max_context_bytes: Option<usize>,
199    pub mcp_allow_remote_clients: Option<bool>,
200    pub audit_sink_enabled: Option<bool>,
201    pub audit_queue_depth: Option<usize>,
202}
203
204/// Retrieval backend settings read from relay-specific environment variables.
205#[derive(Debug, Clone, Default, PartialEq, Eq)]
206pub struct RetrievalEnvOverrides {
207    pub semantic_backend: Option<String>,
208    pub vector_backend: Option<String>,
209    pub llm_provider: Option<String>,
210    pub embedding_base_url: Option<String>,
211    pub embedding_api_key: Option<String>,
212    pub text_embedding_model: Option<String>,
213    pub image_embedding_model: Option<String>,
214    pub embedding_dimension: Option<usize>,
215    pub embedding_batch_size: Option<usize>,
216    pub embedding_timeout_ms: Option<u64>,
217    pub embedding_max_concurrency: Option<usize>,
218    pub rerank_backend: Option<String>,
219    pub rerank_model: Option<String>,
220    pub rerank_timeout_ms: Option<u64>,
221    pub rerank_candidate_multiplier: Option<usize>,
222    pub rerank_max_candidates: Option<usize>,
223}
224
225/// Worker and service-operator settings read from relay-specific environment variables.
226#[derive(Debug, Clone, Default, PartialEq, Eq)]
227pub struct WorkerEnvOverrides {
228    pub embedding_endpoint: Option<String>,
229    pub ocr_endpoint: Option<String>,
230    pub vision_endpoint: Option<String>,
231    pub extractor_endpoint: Option<String>,
232    pub max_in_flight: Option<usize>,
233    pub silent_updates_enabled: Option<bool>,
234}
235
236/// Local file index settings read from relay-specific environment variables.
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
238pub struct FileIndexEnvOverrides {
239    pub enabled: Option<bool>,
240    pub roots: Option<String>,
241    pub excludes: Option<String>,
242    pub max_depth: Option<usize>,
243    pub max_file_bytes: Option<u64>,
244    pub scan_interval_ms: Option<u64>,
245    pub scan_timeout_ms: Option<u64>,
246    pub max_files_per_root: Option<usize>,
247    pub query_timeout_ms: Option<u64>,
248}
249
250/// Release update-check settings read from relay-specific environment variables.
251#[derive(Debug, Clone, Default, PartialEq, Eq)]
252pub struct UpdateEnvOverrides {
253    pub enabled: Option<bool>,
254    pub sources: Option<String>,
255    pub check_interval_ms: Option<u64>,
256    pub github_repo: Option<String>,
257}
258
259/// Telemetry exporter settings read from relay-specific environment variables.
260#[derive(Debug, Clone, Default, PartialEq, Eq)]
261pub struct TelemetryEnvOverrides {
262    pub otel_endpoint: Option<String>,
263    pub otel_traces: Option<bool>,
264    pub otel_metrics: Option<bool>,
265    pub export_timeout_ms: Option<u64>,
266    pub service_environment: Option<String>,
267}
268
269/// Fully parsed process environment relevant to relay-knowledge.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct EnvironmentConfig {
272    pub platform: PlatformEnvironment,
273    pub paths: PathEnvOverrides,
274    pub network: NetworkEnvOverrides,
275    pub agent: AgentEnvOverrides,
276    pub retrieval: RetrievalEnvOverrides,
277    pub workers: WorkerEnvOverrides,
278    pub file_index: FileIndexEnvOverrides,
279    pub updates: UpdateEnvOverrides,
280    pub telemetry: TelemetryEnvOverrides,
281}
282
283impl EnvironmentConfig {
284    /// Reads and validates the current process environment.
285    pub fn from_process() -> Result<Self, EnvError> {
286        Self::from_pairs(PlatformKind::current(), process_env::vars_os())
287    }
288
289    /// Parses a deterministic environment snapshot.
290    pub fn from_pairs<I, K, V>(platform: PlatformKind, pairs: I) -> Result<Self, EnvError>
291    where
292        I: IntoIterator<Item = (K, V)>,
293        K: Into<OsString>,
294        V: Into<OsString>,
295    {
296        let values = pairs
297            .into_iter()
298            .map(|(key, value)| (normalize_key(platform, key.into()), value.into()))
299            .collect::<HashMap<_, _>>();
300        let temp_variables: &[&'static str] = if platform == PlatformKind::Windows {
301            &[TEMP, TMP, TMPDIR]
302        } else {
303            &[TMPDIR, TEMP, TMP]
304        };
305
306        Ok(Self {
307            platform: PlatformEnvironment {
308                platform,
309                home_dir: path_var(&values, HOME)?,
310                xdg_config_home: path_var(&values, XDG_CONFIG_HOME)?,
311                xdg_data_home: path_var(&values, XDG_DATA_HOME)?,
312                xdg_state_home: path_var(&values, XDG_STATE_HOME)?,
313                xdg_cache_home: path_var(&values, XDG_CACHE_HOME)?,
314                xdg_runtime_dir: path_var(&values, XDG_RUNTIME_DIR)?,
315                app_data: path_var(&values, APPDATA)?,
316                local_app_data: path_var(&values, LOCALAPPDATA)?,
317                temp_dir: first_path_var(&values, temp_variables)?,
318            },
319            paths: PathEnvOverrides {
320                home: path_var(&values, RELAY_KNOWLEDGE_HOME)?,
321                config_dir: path_var(&values, RELAY_KNOWLEDGE_CONFIG_DIR)?,
322                data_dir: path_var(&values, RELAY_KNOWLEDGE_DATA_DIR)?,
323                state_dir: path_var(&values, RELAY_KNOWLEDGE_STATE_DIR)?,
324                cache_dir: path_var(&values, RELAY_KNOWLEDGE_CACHE_DIR)?,
325                log_dir: path_var(&values, RELAY_KNOWLEDGE_LOG_DIR)?,
326                temp_dir: path_var(&values, RELAY_KNOWLEDGE_TEMP_DIR)?,
327                runtime_dir: path_var(&values, RELAY_KNOWLEDGE_RUNTIME_DIR)?,
328                service_dir: path_var(&values, RELAY_KNOWLEDGE_SERVICE_DIR)?,
329            },
330            network: NetworkEnvOverrides {
331                http_bind: string_var(&values, RELAY_KNOWLEDGE_HTTP_BIND)?,
332                http_request_timeout_ms: positive_u64_var(
333                    &values,
334                    RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS,
335                )?,
336                http_shutdown_timeout_ms: positive_u64_var(
337                    &values,
338                    RELAY_KNOWLEDGE_HTTP_SHUTDOWN_TIMEOUT_MS,
339                )?,
340                http_max_body_bytes: positive_u64_var(
341                    &values,
342                    RELAY_KNOWLEDGE_HTTP_MAX_BODY_BYTES,
343                )?,
344                proxy: first_string_var(
345                    &values,
346                    &[
347                        HTTPS_PROXY,
348                        HTTPS_PROXY_LOWER,
349                        HTTP_PROXY,
350                        HTTP_PROXY_LOWER,
351                        ALL_PROXY,
352                        ALL_PROXY_LOWER,
353                    ],
354                )?,
355                no_proxy: first_string_var(&values, &[NO_PROXY, NO_PROXY_LOWER])?,
356                ssl_verify: first_bool_var(&values, &[SSL_VERIFY, SSL_VERIFY_LOWER])?,
357                qos_max_connections: positive_usize_var(
358                    &values,
359                    RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS,
360                )?,
361                qos_max_in_flight_requests: positive_usize_var(
362                    &values,
363                    RELAY_KNOWLEDGE_QOS_MAX_IN_FLIGHT_REQUESTS,
364                )?,
365                qos_max_queue_depth: positive_usize_var(
366                    &values,
367                    RELAY_KNOWLEDGE_QOS_MAX_QUEUE_DEPTH,
368                )?,
369            },
370            agent: AgentEnvOverrides {
371                mcp_streamable_http_enabled: bool_var(
372                    &values,
373                    RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED,
374                )?,
375                mcp_endpoint: string_var(&values, RELAY_KNOWLEDGE_MCP_ENDPOINT)?,
376                mcp_allowed_origins: string_var(&values, RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS)?,
377                mcp_allowed_scopes: string_var(&values, RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES)?,
378                mcp_allow_unspecified_scope: bool_var(
379                    &values,
380                    RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE,
381                )?,
382                mcp_max_limit: positive_usize_var(&values, RELAY_KNOWLEDGE_MCP_MAX_LIMIT)?,
383                mcp_max_context_bytes: positive_usize_var(
384                    &values,
385                    RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES,
386                )?,
387                mcp_allow_remote_clients: bool_var(
388                    &values,
389                    RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS,
390                )?,
391                audit_sink_enabled: bool_var(&values, RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED)?,
392                audit_queue_depth: positive_usize_var(
393                    &values,
394                    RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH,
395                )?,
396            },
397            retrieval: RetrievalEnvOverrides {
398                semantic_backend: string_var(&values, RELAY_KNOWLEDGE_SEMANTIC_BACKEND)?,
399                vector_backend: string_var(&values, RELAY_KNOWLEDGE_VECTOR_BACKEND)?,
400                llm_provider: string_var(&values, RELAY_KNOWLEDGE_LLM_PROVIDER)?,
401                embedding_base_url: string_var(&values, RELAY_KNOWLEDGE_EMBEDDING_BASE_URL)?,
402                embedding_api_key: string_var(&values, RELAY_KNOWLEDGE_EMBEDDING_API_KEY)?,
403                text_embedding_model: string_var(&values, RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL)?,
404                image_embedding_model: string_var(&values, RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL)?,
405                embedding_dimension: positive_usize_var(
406                    &values,
407                    RELAY_KNOWLEDGE_EMBEDDING_DIMENSION,
408                )?,
409                embedding_batch_size: positive_usize_var(
410                    &values,
411                    RELAY_KNOWLEDGE_EMBEDDING_BATCH_SIZE,
412                )?,
413                embedding_timeout_ms: positive_u64_var(
414                    &values,
415                    RELAY_KNOWLEDGE_EMBEDDING_TIMEOUT_MS,
416                )?,
417                embedding_max_concurrency: positive_usize_var(
418                    &values,
419                    RELAY_KNOWLEDGE_EMBEDDING_MAX_CONCURRENCY,
420                )?,
421                rerank_backend: string_var(&values, RELAY_KNOWLEDGE_RERANK_BACKEND)?,
422                rerank_model: string_var(&values, RELAY_KNOWLEDGE_RERANK_MODEL)?,
423                rerank_timeout_ms: positive_u64_var(&values, RELAY_KNOWLEDGE_RERANK_TIMEOUT_MS)?,
424                rerank_candidate_multiplier: positive_usize_var(
425                    &values,
426                    RELAY_KNOWLEDGE_RERANK_CANDIDATE_MULTIPLIER,
427                )?,
428                rerank_max_candidates: positive_usize_var(
429                    &values,
430                    RELAY_KNOWLEDGE_RERANK_MAX_CANDIDATES,
431                )?,
432            },
433            workers: WorkerEnvOverrides {
434                embedding_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_EMBEDDING_ENDPOINT)?,
435                ocr_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_OCR_ENDPOINT)?,
436                vision_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_VISION_ENDPOINT)?,
437                extractor_endpoint: string_var(&values, RELAY_KNOWLEDGE_WORKER_EXTRACTOR_ENDPOINT)?,
438                max_in_flight: positive_usize_var(&values, RELAY_KNOWLEDGE_WORKER_MAX_IN_FLIGHT)?,
439                silent_updates_enabled: bool_var(&values, RELAY_KNOWLEDGE_SILENT_UPDATES_ENABLED)?,
440            },
441            file_index: FileIndexEnvOverrides {
442                enabled: bool_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_ENABLED)?,
443                roots: string_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_ROOTS)?,
444                excludes: string_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_EXCLUDES)?,
445                max_depth: positive_usize_var(&values, RELAY_KNOWLEDGE_FILE_INDEX_MAX_DEPTH)?,
446                max_file_bytes: positive_u64_var(
447                    &values,
448                    RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILE_BYTES,
449                )?,
450                scan_interval_ms: positive_u64_var(
451                    &values,
452                    RELAY_KNOWLEDGE_FILE_INDEX_SCAN_INTERVAL_MS,
453                )?,
454                scan_timeout_ms: positive_u64_var(
455                    &values,
456                    RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS,
457                )?,
458                max_files_per_root: positive_usize_var(
459                    &values,
460                    RELAY_KNOWLEDGE_FILE_INDEX_MAX_FILES_PER_ROOT,
461                )?,
462                query_timeout_ms: positive_u64_var(&values, RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS)?,
463            },
464            updates: {
465                let enabled = bool_var(&values, RELAY_KNOWLEDGE_UPDATE_CHECK_ENABLED)?;
466                let check_interval_ms = if enabled == Some(false) {
467                    None
468                } else {
469                    positive_u64_var(&values, RELAY_KNOWLEDGE_UPDATE_CHECK_INTERVAL_MS)?
470                };
471                UpdateEnvOverrides {
472                    enabled,
473                    sources: string_var(&values, RELAY_KNOWLEDGE_UPDATE_SOURCES)?,
474                    check_interval_ms,
475                    github_repo: string_var(&values, RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO)?,
476                }
477            },
478            telemetry: TelemetryEnvOverrides {
479                otel_endpoint: string_var(&values, RELAY_OTEL_ENDPOINT)?,
480                otel_traces: bool_var(&values, RELAY_OTEL_TRACES)?,
481                otel_metrics: bool_var(&values, RELAY_OTEL_METRICS)?,
482                export_timeout_ms: positive_u64_var(&values, RELAY_OTEL_EXPORT_TIMEOUT_MS)?,
483                service_environment: string_var(&values, RELAY_OTEL_SERVICE_ENVIRONMENT)?,
484            },
485        })
486    }
487}
488
489fn normalize_key(platform: PlatformKind, key: OsString) -> OsString {
490    if platform == PlatformKind::Windows {
491        key.to_str()
492            .map(|value| OsString::from(value.to_ascii_uppercase()))
493            .unwrap_or(key)
494    } else {
495        key
496    }
497}
498
499/// Environment parsing error with the exact variable that failed validation.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct EnvError {
502    pub variable: &'static str,
503    pub kind: EnvErrorKind,
504}
505
506impl EnvError {
507    fn empty(variable: &'static str) -> Self {
508        Self {
509            variable,
510            kind: EnvErrorKind::EmptyValue,
511        }
512    }
513
514    fn invalid_unicode(variable: &'static str) -> Self {
515        Self {
516            variable,
517            kind: EnvErrorKind::InvalidUnicode,
518        }
519    }
520
521    fn invalid_integer(variable: &'static str, value: &str) -> Self {
522        Self {
523            variable,
524            kind: EnvErrorKind::InvalidInteger {
525                value: value.to_owned(),
526            },
527        }
528    }
529
530    fn zero(variable: &'static str) -> Self {
531        Self {
532            variable,
533            kind: EnvErrorKind::ZeroValue,
534        }
535    }
536
537    fn invalid_boolean(variable: &'static str, value: &str) -> Self {
538        Self {
539            variable,
540            kind: EnvErrorKind::InvalidBoolean {
541                value: value.to_owned(),
542            },
543        }
544    }
545}
546
547/// Error category for environment parsing.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub enum EnvErrorKind {
550    EmptyValue,
551    InvalidUnicode,
552    InvalidInteger { value: String },
553    InvalidBoolean { value: String },
554    ZeroValue,
555}
556
557impl fmt::Display for EnvError {
558    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
559        match &self.kind {
560            EnvErrorKind::EmptyValue => write!(formatter, "{} must not be empty", self.variable),
561            EnvErrorKind::InvalidUnicode => {
562                write!(formatter, "{} must be valid UTF-8", self.variable)
563            }
564            EnvErrorKind::InvalidInteger { value } => {
565                write!(
566                    formatter,
567                    "{} must be a positive integer, got '{value}'",
568                    self.variable
569                )
570            }
571            EnvErrorKind::InvalidBoolean { value } => write!(
572                formatter,
573                "{} must be true or false, got '{value}'",
574                self.variable
575            ),
576            EnvErrorKind::ZeroValue => {
577                write!(formatter, "{} must be greater than zero", self.variable)
578            }
579        }
580    }
581}
582
583impl Error for EnvError {}
584
585fn path_var(
586    values: &HashMap<OsString, OsString>,
587    variable: &'static str,
588) -> Result<Option<PathBuf>, EnvError> {
589    values
590        .get(OsStr::new(variable))
591        .map(|value| {
592            reject_empty(value, variable)?;
593            Ok(PathBuf::from(value))
594        })
595        .transpose()
596}
597
598fn first_path_var(
599    values: &HashMap<OsString, OsString>,
600    variables: &[&'static str],
601) -> Result<Option<PathBuf>, EnvError> {
602    for variable in variables {
603        if let Some(value) = path_var(values, variable)? {
604            return Ok(Some(value));
605        }
606    }
607
608    Ok(None)
609}
610
611fn string_var(
612    values: &HashMap<OsString, OsString>,
613    variable: &'static str,
614) -> Result<Option<String>, EnvError> {
615    values
616        .get(OsStr::new(variable))
617        .map(|value| {
618            reject_empty(value, variable)?;
619            value
620                .to_str()
621                .map(str::to_owned)
622                .ok_or_else(|| EnvError::invalid_unicode(variable))
623        })
624        .transpose()
625}
626
627fn first_string_var(
628    values: &HashMap<OsString, OsString>,
629    variables: &[&'static str],
630) -> Result<Option<String>, EnvError> {
631    for variable in variables {
632        if let Some(value) = string_var(values, variable)? {
633            return Ok(Some(value));
634        }
635    }
636
637    Ok(None)
638}
639
640fn bool_var(
641    values: &HashMap<OsString, OsString>,
642    variable: &'static str,
643) -> Result<Option<bool>, EnvError> {
644    string_var(values, variable)?
645        .map(|value| parse_bool(variable, &value))
646        .transpose()
647}
648
649fn first_bool_var(
650    values: &HashMap<OsString, OsString>,
651    variables: &[&'static str],
652) -> Result<Option<bool>, EnvError> {
653    for variable in variables {
654        if let Some(value) = bool_var(values, variable)? {
655            return Ok(Some(value));
656        }
657    }
658
659    Ok(None)
660}
661
662fn positive_u64_var(
663    values: &HashMap<OsString, OsString>,
664    variable: &'static str,
665) -> Result<Option<u64>, EnvError> {
666    string_var(values, variable)?
667        .map(|value| parse_positive_u64(variable, &value))
668        .transpose()
669}
670
671fn positive_usize_var(
672    values: &HashMap<OsString, OsString>,
673    variable: &'static str,
674) -> Result<Option<usize>, EnvError> {
675    string_var(values, variable)?
676        .map(|value| parse_positive_usize(variable, &value))
677        .transpose()
678}
679
680fn parse_positive_u64(variable: &'static str, value: &str) -> Result<u64, EnvError> {
681    let parsed = value
682        .parse::<u64>()
683        .map_err(|_| EnvError::invalid_integer(variable, value))?;
684
685    if parsed == 0 {
686        return Err(EnvError::zero(variable));
687    }
688
689    Ok(parsed)
690}
691
692fn parse_positive_usize(variable: &'static str, value: &str) -> Result<usize, EnvError> {
693    let parsed = value
694        .parse::<usize>()
695        .map_err(|_| EnvError::invalid_integer(variable, value))?;
696
697    if parsed == 0 {
698        return Err(EnvError::zero(variable));
699    }
700
701    Ok(parsed)
702}
703
704fn parse_bool(variable: &'static str, value: &str) -> Result<bool, EnvError> {
705    match value.to_ascii_lowercase().as_str() {
706        "true" | "1" | "yes" | "on" => Ok(true),
707        "false" | "0" | "no" | "off" => Ok(false),
708        _ => Err(EnvError::invalid_boolean(variable, value)),
709    }
710}
711
712fn reject_empty(value: &OsString, variable: &'static str) -> Result<(), EnvError> {
713    if value.is_empty() {
714        return Err(EnvError::empty(variable));
715    }
716
717    Ok(())
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    #[test]
725    fn parses_platform_and_relay_overrides() {
726        let config = EnvironmentConfig::from_pairs(
727            PlatformKind::Unix,
728            [
729                (HOME, "/home/alice"),
730                (TMPDIR, "/var/tmp"),
731                (RELAY_KNOWLEDGE_HOME, "/opt/relay-runtime"),
732                (RELAY_KNOWLEDGE_HTTP_BIND, "127.0.0.1:9000"),
733                (HTTPS_PROXY, "https://proxy.internal:8443"),
734                (NO_PROXY, "localhost,.internal"),
735                (SSL_VERIFY, "false"),
736                (RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS, "512"),
737                (RELAY_KNOWLEDGE_MCP_STREAMABLE_HTTP_ENABLED, "true"),
738                (RELAY_KNOWLEDGE_MCP_ENDPOINT, "/mcp"),
739                (RELAY_KNOWLEDGE_MCP_ALLOWED_ORIGINS, "http://localhost:8791"),
740                (RELAY_KNOWLEDGE_MCP_ALLOWED_SCOPES, "docs,src"),
741                (RELAY_KNOWLEDGE_MCP_ALLOW_UNSPECIFIED_SCOPE, "true"),
742                (RELAY_KNOWLEDGE_MCP_MAX_LIMIT, "5"),
743                (RELAY_KNOWLEDGE_MCP_MAX_CONTEXT_BYTES, "8192"),
744                (RELAY_KNOWLEDGE_MCP_ALLOW_REMOTE_CLIENTS, "false"),
745                (RELAY_KNOWLEDGE_AGENT_AUDIT_SINK_ENABLED, "true"),
746                (RELAY_KNOWLEDGE_AGENT_AUDIT_QUEUE_DEPTH, "256"),
747                (RELAY_KNOWLEDGE_SEMANTIC_BACKEND, "external"),
748                (RELAY_KNOWLEDGE_VECTOR_BACKEND, "external"),
749                (RELAY_KNOWLEDGE_TEXT_EMBEDDING_MODEL, "text-embed-3-small"),
750                (RELAY_KNOWLEDGE_IMAGE_EMBEDDING_MODEL, "clip-vit-b32"),
751                (RELAY_KNOWLEDGE_EMBEDDING_DIMENSION, "1536"),
752                (RELAY_KNOWLEDGE_FILE_INDEX_SCAN_TIMEOUT_MS, "120000"),
753                (RELAY_KNOWLEDGE_FILE_QUERY_TIMEOUT_MS, "600"),
754                (RELAY_KNOWLEDGE_UPDATE_CHECK_ENABLED, "true"),
755                (RELAY_KNOWLEDGE_UPDATE_SOURCES, "github,crates.io"),
756                (RELAY_KNOWLEDGE_UPDATE_CHECK_INTERVAL_MS, "86400000"),
757                (
758                    RELAY_KNOWLEDGE_UPDATE_GITHUB_REPO,
759                    "coolplayagent/relay-knowledge",
760                ),
761                (RELAY_OTEL_ENDPOINT, "http://collector.internal:4318"),
762                (RELAY_OTEL_TRACES, "true"),
763                (RELAY_OTEL_METRICS, "false"),
764                (RELAY_OTEL_EXPORT_TIMEOUT_MS, "1500"),
765                (RELAY_OTEL_SERVICE_ENVIRONMENT, "test"),
766            ],
767        )
768        .expect("environment should parse");
769
770        assert_eq!(config.platform.platform, PlatformKind::Unix);
771        assert_eq!(config.platform.home_dir, Some(PathBuf::from("/home/alice")));
772        assert_eq!(config.platform.temp_dir, Some(PathBuf::from("/var/tmp")));
773        assert_eq!(config.paths.home, Some(PathBuf::from("/opt/relay-runtime")));
774        assert_eq!(config.network.http_bind, Some("127.0.0.1:9000".to_owned()));
775        assert_eq!(
776            config.network.proxy,
777            Some("https://proxy.internal:8443".to_owned())
778        );
779        assert_eq!(
780            config.network.no_proxy,
781            Some("localhost,.internal".to_owned())
782        );
783        assert_eq!(config.network.ssl_verify, Some(false));
784        assert_eq!(config.network.qos_max_connections, Some(512));
785        assert_eq!(config.agent.mcp_streamable_http_enabled, Some(true));
786        assert_eq!(config.agent.mcp_endpoint, Some("/mcp".to_owned()));
787        assert_eq!(
788            config.agent.mcp_allowed_origins,
789            Some("http://localhost:8791".to_owned())
790        );
791        assert_eq!(config.agent.mcp_allowed_scopes, Some("docs,src".to_owned()));
792        assert_eq!(config.agent.mcp_allow_unspecified_scope, Some(true));
793        assert_eq!(config.agent.mcp_max_limit, Some(5));
794        assert_eq!(config.agent.mcp_max_context_bytes, Some(8192));
795        assert_eq!(config.agent.mcp_allow_remote_clients, Some(false));
796        assert_eq!(config.agent.audit_sink_enabled, Some(true));
797        assert_eq!(config.agent.audit_queue_depth, Some(256));
798        assert_eq!(
799            config.retrieval.semantic_backend,
800            Some("external".to_owned())
801        );
802        assert_eq!(config.retrieval.vector_backend, Some("external".to_owned()));
803        assert_eq!(
804            config.retrieval.text_embedding_model,
805            Some("text-embed-3-small".to_owned())
806        );
807        assert_eq!(
808            config.retrieval.image_embedding_model,
809            Some("clip-vit-b32".to_owned())
810        );
811        assert_eq!(config.retrieval.embedding_dimension, Some(1536));
812        assert_eq!(config.file_index.scan_timeout_ms, Some(120000));
813        assert_eq!(config.file_index.query_timeout_ms, Some(600));
814        assert_eq!(config.updates.enabled, Some(true));
815        assert_eq!(config.updates.sources, Some("github,crates.io".to_owned()));
816        assert_eq!(config.updates.check_interval_ms, Some(86_400_000));
817        assert_eq!(
818            config.updates.github_repo,
819            Some("coolplayagent/relay-knowledge".to_owned())
820        );
821        assert_eq!(
822            config.telemetry.otel_endpoint,
823            Some("http://collector.internal:4318".to_owned())
824        );
825        assert_eq!(config.telemetry.otel_traces, Some(true));
826        assert_eq!(config.telemetry.otel_metrics, Some(false));
827        assert_eq!(config.telemetry.export_timeout_ms, Some(1500));
828        assert_eq!(
829            config.telemetry.service_environment,
830            Some("test".to_owned())
831        );
832    }
833
834    #[test]
835    fn rejects_empty_path_values() {
836        let error = EnvironmentConfig::from_pairs(PlatformKind::Unix, [(RELAY_KNOWLEDGE_HOME, "")])
837            .expect_err("empty path should fail");
838
839        assert_eq!(error.variable, RELAY_KNOWLEDGE_HOME);
840        assert_eq!(error.kind, EnvErrorKind::EmptyValue);
841    }
842
843    #[test]
844    fn rejects_invalid_numeric_values() {
845        let error = EnvironmentConfig::from_pairs(
846            PlatformKind::Unix,
847            [(RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS, "many")],
848        )
849        .expect_err("invalid numeric value should fail");
850
851        assert_eq!(error.variable, RELAY_KNOWLEDGE_QOS_MAX_CONNECTIONS);
852        assert_eq!(
853            error.kind,
854            EnvErrorKind::InvalidInteger {
855                value: "many".to_owned()
856            }
857        );
858    }
859
860    #[test]
861    fn rejects_zero_numeric_values() {
862        let error = EnvironmentConfig::from_pairs(
863            PlatformKind::Unix,
864            [(RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS, "0")],
865        )
866        .expect_err("zero timeout should fail");
867
868        assert_eq!(error.variable, RELAY_KNOWLEDGE_HTTP_REQUEST_TIMEOUT_MS);
869        assert_eq!(error.kind, EnvErrorKind::ZeroValue);
870    }
871
872    #[test]
873    fn disabled_update_checks_ignore_invalid_interval_override() {
874        let config = EnvironmentConfig::from_pairs(
875            PlatformKind::Unix,
876            [
877                (RELAY_KNOWLEDGE_UPDATE_CHECK_ENABLED, "false"),
878                (RELAY_KNOWLEDGE_UPDATE_CHECK_INTERVAL_MS, "0"),
879            ],
880        )
881        .expect("disabled update checks should ignore unused interval settings");
882
883        assert_eq!(config.updates.enabled, Some(false));
884        assert_eq!(config.updates.check_interval_ms, None);
885    }
886
887    #[test]
888    fn rejects_zero_otel_export_timeout() {
889        let error = EnvironmentConfig::from_pairs(
890            PlatformKind::Unix,
891            [(RELAY_OTEL_EXPORT_TIMEOUT_MS, "0")],
892        )
893        .expect_err("zero otel export timeout should fail");
894
895        assert_eq!(error.variable, RELAY_OTEL_EXPORT_TIMEOUT_MS);
896        assert_eq!(error.kind, EnvErrorKind::ZeroValue);
897    }
898
899    #[test]
900    fn rejects_invalid_otel_boolean_values() {
901        let error =
902            EnvironmentConfig::from_pairs(PlatformKind::Unix, [(RELAY_OTEL_TRACES, "yes?")])
903                .expect_err("invalid otel boolean should fail");
904
905        assert_eq!(error.variable, RELAY_OTEL_TRACES);
906        assert_eq!(
907            error.kind,
908            EnvErrorKind::InvalidBoolean {
909                value: "yes?".to_owned()
910            }
911        );
912    }
913
914    #[test]
915    fn https_proxy_takes_precedence_over_http_proxy() {
916        let config = EnvironmentConfig::from_pairs(
917            PlatformKind::Unix,
918            [
919                (HTTP_PROXY, "http://http-proxy:8080"),
920                (HTTPS_PROXY, "http://https-proxy:8080"),
921                (NO_PROXY, "localhost"),
922            ],
923        )
924        .expect("environment should parse");
925
926        assert_eq!(
927            config.network.proxy,
928            Some("http://https-proxy:8080".to_owned())
929        );
930        assert_eq!(config.network.no_proxy, Some("localhost".to_owned()));
931    }
932
933    #[test]
934    fn rejects_invalid_boolean_values() {
935        let error = EnvironmentConfig::from_pairs(PlatformKind::Unix, [(SSL_VERIFY, "sometimes")])
936            .expect_err("invalid boolean should fail");
937
938        assert_eq!(error.variable, SSL_VERIFY);
939        assert_eq!(
940            error.kind,
941            EnvErrorKind::InvalidBoolean {
942                value: "sometimes".to_owned()
943            }
944        );
945    }
946
947    #[test]
948    fn windows_environment_names_are_case_insensitive() {
949        let config = EnvironmentConfig::from_pairs(
950            PlatformKind::Windows,
951            [
952                ("home", "/home/alice"),
953                ("appdata", "/roaming"),
954                ("localappdata", "/local"),
955                ("relay_knowledge_http_bind", "localhost:8791"),
956                ("ssl_verify", "off"),
957            ],
958        )
959        .expect("environment should parse");
960
961        assert_eq!(config.platform.home_dir, Some(PathBuf::from("/home/alice")));
962        assert_eq!(config.platform.app_data, Some(PathBuf::from("/roaming")));
963        assert_eq!(
964            config.platform.local_app_data,
965            Some(PathBuf::from("/local"))
966        );
967        assert_eq!(config.network.http_bind, Some("localhost:8791".to_owned()));
968        assert_eq!(config.network.ssl_verify, Some(false));
969    }
970
971    #[test]
972    fn windows_temp_prefers_temp_tmp_before_tmpdir() {
973        let config = EnvironmentConfig::from_pairs(
974            PlatformKind::Windows,
975            [
976                (TMPDIR, "/posix/tmp"),
977                (TEMP, "/windows/temp"),
978                (TMP, "/windows/tmp"),
979            ],
980        )
981        .expect("environment should parse");
982
983        assert_eq!(
984            config.platform.temp_dir,
985            Some(PathBuf::from("/windows/temp"))
986        );
987    }
988}