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_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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum PlatformKind {
124 Unix,
125 Macos,
126 Windows,
127 Other,
128}
129
130impl PlatformKind {
131 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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 pub fn from_process() -> Result<Self, EnvError> {
286 Self::from_pairs(PlatformKind::current(), process_env::vars_os())
287 }
288
289 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#[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#[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}