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