Skip to main content

relay_knowledge/env/
mod.rs

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