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_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/// Operating-system family used by path resolution.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum PlatformKind {
128    Unix,
129    Macos,
130    Windows,
131    Other,
132}
133
134impl PlatformKind {
135    /// Detects the current target platform without consulting environment state.
136    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/// Platform directory inputs captured from the environment.
150#[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/// Relay-specific path overrides read from environment variables.
165#[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/// Network settings read from relay-specific and generic environment variables.
179#[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/// Remote service settings read from relay-specific environment variables.
194#[derive(Debug, Clone, Default, PartialEq, Eq)]
195pub struct RemoteCliEnvOverrides {
196    pub base_url: Option<String>,
197}
198
199/// Agent protocol settings read from relay-specific environment variables.
200#[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/// Retrieval backend settings read from relay-specific environment variables.
215#[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/// Worker and service-operator settings read from relay-specific environment variables.
236#[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/// Local file index settings read from relay-specific environment variables.
248#[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/// Release update-check settings read from relay-specific environment variables.
262#[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/// Telemetry exporter settings read from relay-specific environment variables.
271#[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/// Fully parsed process environment relevant to relay-knowledge.
281#[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/// Environment subset needed before a remote CLI command can dispatch over HTTP.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct RemoteCliEnvironmentConfig {
299    pub network: NetworkEnvOverrides,
300    pub remote_cli: RemoteCliEnvOverrides,
301}
302
303impl RemoteCliEnvironmentConfig {
304    /// Reads the current process environment for remote CLI dispatch only.
305    pub fn from_process() -> Result<Self, EnvError> {
306        Self::from_pairs(PlatformKind::current(), process_env::vars_os())
307    }
308
309    /// Parses a deterministic remote CLI environment snapshot.
310    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    /// Reads and validates the current process environment.
327    pub fn from_process() -> Result<Self, EnvError> {
328        Self::from_pairs(PlatformKind::current(), process_env::vars_os())
329    }
330
331    /// Parses a deterministic environment snapshot.
332    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/// Environment parsing error with the exact variable that failed validation.
559#[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/// Error category for environment parsing.
607#[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}