1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum PlatformKind {
126 Unix,
127 Macos,
128 Windows,
129 Other,
130}
131
132impl PlatformKind {
133 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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 pub fn from_process() -> Result<Self, EnvError> {
289 Self::from_pairs(PlatformKind::current(), process_env::vars_os())
290 }
291
292 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#[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#[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}