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_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/// Operating-system family used by path resolution.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum PlatformKind {
133    Unix,
134    Macos,
135    Windows,
136    Other,
137}
138
139impl PlatformKind {
140    /// Detects the current target platform without consulting environment state.
141    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/// Platform directory inputs captured from the environment.
155#[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/// Relay-specific path overrides read from environment variables.
170#[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/// Network settings read from relay-specific and generic environment variables.
184#[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/// Remote service settings read from relay-specific environment variables.
199#[derive(Debug, Clone, Default, PartialEq, Eq)]
200pub struct RemoteCliEnvOverrides {
201    pub base_url: Option<String>,
202}
203
204/// Agent protocol settings read from relay-specific environment variables.
205#[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/// Retrieval backend settings read from relay-specific environment variables.
220#[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/// Worker and service-operator settings read from relay-specific environment variables.
241#[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/// Local file index settings read from relay-specific environment variables.
253#[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/// Release update-check settings read from relay-specific environment variables.
267#[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/// Telemetry exporter settings read from relay-specific environment variables.
276#[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/// File watcher settings read from relay-specific environment variables.
286#[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/// Fully parsed process environment relevant to relay-knowledge.
295#[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/// Environment subset needed before a remote CLI command can dispatch over HTTP.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct RemoteCliEnvironmentConfig {
314    pub network: NetworkEnvOverrides,
315    pub remote_cli: RemoteCliEnvOverrides,
316}
317
318impl RemoteCliEnvironmentConfig {
319    /// Reads the current process environment for remote CLI dispatch only.
320    pub fn from_process() -> Result<Self, EnvError> {
321        Self::from_pairs(PlatformKind::current(), process_env::vars_os())
322    }
323
324    /// Parses a deterministic remote CLI environment snapshot.
325    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    /// Reads and validates the current process environment.
342    pub fn from_process() -> Result<Self, EnvError> {
343        Self::from_pairs(PlatformKind::current(), process_env::vars_os())
344    }
345
346    /// Parses a deterministic environment snapshot.
347    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/// Environment parsing error with the exact variable that failed validation.
586#[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/// Error category for environment parsing.
634#[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}