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