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