Skip to main content

soma_config/
config.rs

1//! Configuration structs for the Soma MCP server.
2//!
3//! Values are loaded in priority order:
4//!   1. `config.toml` (checked in, defaults only — no secrets)
5//!   2. Environment variables (`SOMA_*`, `SOMA_MCP_*`)
6//!
7//! **Customize**: rename `SomaConfig` to match your service. Adjust env prefixes
8//! throughout. Add any domain-specific config fields you need.
9
10use serde::{Deserialize, Serialize};
11
12/// CUSTOMIZE: Replace with your service name (e.g. ".unraid", ".gotify").
13const SERVICE_HOME_DIRNAME: &str = ".soma";
14
15/// Top-level config (maps to `config.toml` sections).
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17#[serde(default)]
18pub struct Config {
19    pub mcp: McpConfig,
20    pub soma: SomaConfig,
21}
22
23/// Config for the Soma runtime or deployed platform API.
24///
25/// For application/platform servers, the local CLI + stdio MCP adapter uses
26/// `api_url` as the deployed `soma serve` API base URL. For upstream-client
27/// servers, replace this with config for the actual upstream service.
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29#[serde(default)]
30pub struct SomaConfig {
31    /// Base URL of the deployed platform API or upstream service (SOMA_API_URL).
32    /// Example: `https://example.example.com/`
33    pub api_url: String,
34    /// API key or bearer token (SOMA_API_KEY).
35    pub api_key: String,
36    /// Runtime adapter mode (SOMA_RUNTIME_MODE).
37    pub runtime_mode: RuntimeMode,
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
41#[serde(rename_all = "lowercase")]
42pub enum RuntimeMode {
43    /// Preserve compatibility: remote when SOMA_API_URL is set, otherwise local.
44    #[default]
45    Auto,
46    /// Execute business actions in this process.
47    Local,
48    /// Treat this binary as a client adapter for a running HTTP server.
49    Remote,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum EffectiveRuntimeMode {
54    Local,
55    Remote,
56}
57
58impl SomaConfig {
59    pub fn effective_runtime_mode(&self) -> EffectiveRuntimeMode {
60        match self.runtime_mode {
61            RuntimeMode::Auto if self.api_url.trim().is_empty() => EffectiveRuntimeMode::Local,
62            RuntimeMode::Auto => EffectiveRuntimeMode::Remote,
63            RuntimeMode::Local => EffectiveRuntimeMode::Local,
64            RuntimeMode::Remote => EffectiveRuntimeMode::Remote,
65        }
66    }
67
68    pub fn is_remote_adapter(&self) -> bool {
69        self.effective_runtime_mode() == EffectiveRuntimeMode::Remote
70    }
71}
72
73/// MCP HTTP server configuration.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(default)]
76pub struct McpConfig {
77    /// Bind host (SOMA_MCP_HOST). Default: `127.0.0.1` (loopback).
78    /// Set to `0.0.0.0` to listen on all interfaces — requires auth configured.
79    #[serde(default = "default_mcp_host")]
80    pub host: String,
81    /// Bind port (SOMA_MCP_PORT). Default: `40060`.
82    #[serde(default = "default_mcp_port")]
83    pub port: u16,
84    /// MCP server name advertised to clients (SOMA_MCP_SERVER_NAME).
85    #[serde(default = "default_server_name")]
86    pub server_name: String,
87    /// Disable auth entirely — only safe when bound to loopback (SOMA_MCP_NO_AUTH).
88    pub no_auth: bool,
89    /// Allow unauthenticated access on non-loopback when behind a trusted reverse proxy
90    /// that enforces its own auth (SOMA_NOAUTH). Loaded here so it participates in
91    /// typed config rather than being a raw env read at call sites.
92    pub trusted_gateway: bool,
93    /// Advertise official MCP conformance reference fixtures.
94    ///
95    /// This is a test harness switch, not part of Soma's production
96    /// surface. Keep it false for real derived servers.
97    pub conformance_fixtures: bool,
98    /// Static bearer token for simple auth (SOMA_MCP_TOKEN).
99    pub api_token: Option<String>,
100    /// Grant the static bearer token `soma:write` in addition to the
101    /// default `soma:read` (SOMA_MCP_STATIC_TOKEN_WRITE). Off by default so
102    /// a leaked static token cannot perform write actions unless the
103    /// operator explicitly opted in (pattern ported from cortex's
104    /// `static_token_is_admin`).
105    pub static_token_write: bool,
106    /// Additional allowed Host header values (comma-separated in env).
107    pub allowed_hosts: Vec<String>,
108    /// Additional allowed CORS origins (comma-separated in env).
109    pub allowed_origins: Vec<String>,
110    /// Trusted HTTP trace-header extraction mode (SOMA_MCP_TRACE_HEADERS).
111    /// Only meaningful when the resolved auth policy is a real trust boundary
112    /// (loopback bind or a trusted gateway) — see
113    /// `soma_runtime::server::resolve_auth_policy_kind`.
114    pub trace_headers: TraceHeaderMode,
115    /// OAuth sub-config (nested under `[mcp.auth]` in config.toml).
116    pub auth: AuthConfig,
117}
118
119impl McpConfig {
120    pub fn bind_addr(&self) -> String {
121        format!("{}:{}", self.host, self.port)
122    }
123
124    /// Return true if the configured bind host resolves to a loopback address.
125    ///
126    /// Uses `IpAddr::is_loopback()` for numeric addresses. Accepts "localhost"
127    /// as a canonical loopback hostname. Any other hostname or parse failure is
128    /// treated as non-loopback — callers must not assume safety in that case.
129    pub fn is_loopback(&self) -> bool {
130        let host = &self.host;
131        // Match "localhost" literal and numeric loopback addresses.
132        // Strip bracket notation ([::1]) before parsing so IPv6 loopback works.
133        host == "localhost"
134            || host
135                .trim_start_matches('[')
136                .trim_end_matches(']')
137                .parse::<std::net::IpAddr>()
138                .map(|ip| ip.is_loopback())
139                .unwrap_or(false)
140    }
141}
142
143/// OAuth / JWT auth sub-config.
144///
145/// This struct types every env var `soma_auth::AuthConfigBuilder` consumes
146/// (`SOMA_MCP_*`). Fields left unset (`None` / empty) are deliberately NOT
147/// given soma-side defaults: `soma_integrations::auth` synthesizes a var list
148/// from set fields only, so the auth crate's own defaults (see
149/// `crates/shared/auth/src/config.rs`) apply exactly as they would have when
150/// the builder read process env directly.
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152#[serde(default)]
153pub struct AuthConfig {
154    pub mode: AuthMode,
155    /// Public base URL for OAuth metadata (SOMA_MCP_PUBLIC_URL).
156    pub public_url: Option<String>,
157    /// Google OAuth client ID (SOMA_MCP_GOOGLE_CLIENT_ID).
158    pub google_client_id: Option<String>,
159    /// Google OAuth client secret (SOMA_MCP_GOOGLE_CLIENT_SECRET).
160    pub google_client_secret: Option<String>,
161    /// Google OAuth callback path override (SOMA_MCP_GOOGLE_CALLBACK_PATH).
162    pub google_callback_path: Option<String>,
163    /// Google OAuth scopes override (SOMA_MCP_GOOGLE_SCOPES, comma-separated in env).
164    pub google_scopes: Vec<String>,
165    /// Authelia OIDC issuer URL (SOMA_MCP_AUTHELIA_ISSUER_URL, must be https).
166    pub authelia_issuer_url: Option<String>,
167    /// Authelia OIDC client ID (SOMA_MCP_AUTHELIA_CLIENT_ID).
168    pub authelia_client_id: Option<String>,
169    /// Authelia OIDC client secret (SOMA_MCP_AUTHELIA_CLIENT_SECRET).
170    pub authelia_client_secret: Option<String>,
171    /// Authelia callback path override (SOMA_MCP_AUTHELIA_CALLBACK_PATH).
172    pub authelia_callback_path: Option<String>,
173    /// Authelia scopes override (SOMA_MCP_AUTHELIA_SCOPES, comma-separated in env).
174    pub authelia_scopes: Vec<String>,
175    /// GitHub OAuth App client ID (SOMA_MCP_GITHUB_CLIENT_ID).
176    pub github_client_id: Option<String>,
177    /// GitHub OAuth App client secret (SOMA_MCP_GITHUB_CLIENT_SECRET).
178    pub github_client_secret: Option<String>,
179    /// GitHub callback path override (SOMA_MCP_GITHUB_CALLBACK_PATH).
180    pub github_callback_path: Option<String>,
181    /// GitHub scopes override (SOMA_MCP_GITHUB_SCOPES, comma-separated in env;
182    /// must include `user:email`).
183    pub github_scopes: Vec<String>,
184    /// Default OAuth provider (SOMA_MCP_AUTH_DEFAULT_PROVIDER). Unset =
185    /// automatic priority: Google, Authelia, GitHub.
186    pub default_provider: Option<String>,
187    /// OAuth admin email (SOMA_MCP_AUTH_ADMIN_EMAIL).
188    pub admin_email: String,
189    pub allowed_emails: Vec<String>,
190    /// Native-flow bootstrap secret (SOMA_MCP_AUTH_BOOTSTRAP_SECRET).
191    pub bootstrap_secret: Option<String>,
192    /// Auth SQLite DB path (SOMA_MCP_AUTH_SQLITE_PATH).
193    pub sqlite_path: Option<String>,
194    /// Ed25519 JWT signing key path (SOMA_MCP_AUTH_KEY_PATH).
195    pub key_path: Option<String>,
196    /// Access-token TTL in seconds (SOMA_MCP_AUTH_ACCESS_TOKEN_TTL_SECS).
197    pub access_token_ttl_secs: Option<u64>,
198    /// Refresh-token TTL in seconds (SOMA_MCP_AUTH_REFRESH_TOKEN_TTL_SECS).
199    pub refresh_token_ttl_secs: Option<u64>,
200    /// Auth-code TTL in seconds (SOMA_MCP_AUTH_CODE_TTL_SECS).
201    pub auth_code_ttl_secs: Option<u64>,
202    /// `/register` rate limit (SOMA_MCP_AUTH_REGISTER_REQUESTS_PER_MINUTE).
203    pub register_rpm: Option<u32>,
204    /// `/authorize` rate limit (SOMA_MCP_AUTH_AUTHORIZE_REQUESTS_PER_MINUTE).
205    pub authorize_rpm: Option<u32>,
206    /// Pending OAuth state cap (SOMA_MCP_AUTH_MAX_PENDING_OAUTH_STATES).
207    pub max_pending_oauth_states: Option<usize>,
208    /// Allowed dynamic-client redirect URIs (SOMA_MCP_AUTH_ALLOWED_REDIRECT_URIS,
209    /// comma-separated in env).
210    pub allowed_client_redirect_uris: Vec<String>,
211    /// At-rest refresh-token encryption key (SOMA_MCP_TOKEN_ENCRYPTION_KEY,
212    /// 64 hex digits or 43 base64url chars — validated by soma-auth).
213    pub token_encryption_key: Option<String>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
217#[serde(rename_all = "lowercase")]
218pub enum AuthMode {
219    #[default]
220    Bearer,
221    OAuth,
222}
223
224#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
225#[serde(rename_all = "kebab-case")]
226pub enum TraceHeaderMode {
227    /// No HTTP trace-header extraction. Default — safe for every deployment.
228    #[default]
229    Off,
230    /// Extract `traceparent`/`tracestate` from inbound HTTP headers after auth.
231    /// Baggage is never extracted in this mode.
232    Trusted,
233    /// Like `Trusted`, but also extracts validated `baggage`. Baggage can carry
234    /// sensitive user/session/application data — enable deliberately.
235    TrustedWithBaggage,
236}
237
238impl TraceHeaderMode {
239    pub const fn as_str(self) -> &'static str {
240        match self {
241            Self::Off => "off",
242            Self::Trusted => "trusted",
243            Self::TrustedWithBaggage => "trusted-with-baggage",
244        }
245    }
246}
247
248impl std::fmt::Display for TraceHeaderMode {
249    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        formatter.write_str(self.as_str())
251    }
252}
253
254// ── defaults ──────────────────────────────────────────────────────────────────
255
256fn default_mcp_host() -> String {
257    // Default to loopback for safety. Operators who need external access must
258    // explicitly set SOMA_MCP_HOST=0.0.0.0 (and configure auth).
259    "127.0.0.1".into()
260}
261fn default_mcp_port() -> u16 {
262    40060
263}
264fn default_server_name() -> String {
265    "soma".into()
266}
267
268impl Default for McpConfig {
269    fn default() -> Self {
270        Self {
271            host: default_mcp_host(),
272            port: default_mcp_port(),
273            server_name: default_server_name(),
274            no_auth: false,
275            trusted_gateway: false,
276            conformance_fixtures: false,
277            api_token: None,
278            static_token_write: false,
279            allowed_hosts: Vec::new(),
280            allowed_origins: Vec::new(),
281            trace_headers: TraceHeaderMode::default(),
282            auth: AuthConfig::default(),
283        }
284    }
285}
286
287// ── Appdata directory ─────────────────────────────────────────────────────────
288
289/// Return the default local data directory for this service.
290///
291/// Pattern §25 + §28: The same `.env` and `config.toml` in `~/.<service>/`
292/// work for both Docker and bare-metal deployment without modification.
293///
294/// | Environment   | Path                                |
295/// |---------------|-------------------------------------|
296/// | Container     | `/data` (bind-mounted from host)     |
297/// | Bare-metal    | `~/.soma` (user home dir)        |
298///
299/// The name should match the docker-compose.yml volume mount source.
300pub fn default_data_dir() -> anyhow::Result<std::path::PathBuf> {
301    if let Some(path) = std::env::var_os("SOMA_HOME") {
302        return Ok(std::path::PathBuf::from(path));
303    }
304
305    // Running inside a Docker container — /data is always the mount point.
306    // Detection uses /.dockerenv (created by the Docker runtime) or an explicit
307    // RUNNING_IN_CONTAINER env var (useful for testing or systemd-nspawn).
308    if std::path::Path::new("/.dockerenv").exists()
309        || std::env::var("RUNNING_IN_CONTAINER").is_ok()
310        || std::env::var("container").is_ok()
311    {
312        return Ok(std::path::PathBuf::from("/data"));
313    }
314
315    // Bare-metal or local dev — use ~/.<service>/
316    let home = dirs::home_dir().ok_or_else(|| {
317        anyhow::anyhow!("cannot determine home directory — set HOME or RUNNING_IN_CONTAINER=1")
318    })?;
319    Ok(home.join(SERVICE_HOME_DIRNAME))
320}
321
322/// Load `<appdata>/.env` (`~/.<service>/.env` on bare metal, `/data/.env` in a
323/// container) into the process environment if present.
324///
325/// Best-effort: a missing file is ignored, and existing env vars are NOT
326/// overridden — values injected by docker-compose/systemd or the plugin hook's
327/// `CLAUDE_PLUGIN_OPTION_*` mapping still take precedence. This lets the binary
328/// find its credentials directly from `~/.<service>/.env` without relying on a
329/// process manager to inject them. Call once at startup before `Config::load`.
330pub fn load_dotenv() {
331    let Ok(dir) = default_data_dir() else {
332        return;
333    };
334    let env_path = dir.join(".env");
335    // Reject symlinks under the appdata dir — it holds secrets and we do not want
336    // a planted symlink redirecting us to attacker-controlled env (mirrors axon).
337    // Bare `dotenvy::from_path` would follow the symlink via `File::open`.
338    match std::fs::symlink_metadata(&env_path) {
339        Ok(md) if md.file_type().is_symlink() => {
340            eprintln!(
341                "error: refusing to load symlinked .env at {} (potential symlink attack)",
342                env_path.display()
343            );
344            std::process::exit(1);
345        }
346        Ok(_) => {
347            let _ = dotenvy::from_path(&env_path);
348        }
349        // Missing or inaccessible — best effort; fall back to process env / config.toml.
350        Err(_) => {}
351    }
352}
353
354// ── Config loading ────────────────────────────────────────────────────────────
355
356impl Config {
357    pub fn load() -> anyhow::Result<Self> {
358        let mut config = Config::default();
359
360        // Search for config.toml in priority order (§25: appdata convention):
361        //   1. ~/<SERVICE_HOME_DIRNAME>/config.toml  — user's persistent config (primary)
362        //   2. ./config.toml                         — local dev / Docker mount fallback
363        let candidate_paths = {
364            let mut paths = vec![];
365            if let Some(home) = std::env::var_os("HOME") {
366                paths.push(
367                    std::path::PathBuf::from(home)
368                        .join(SERVICE_HOME_DIRNAME)
369                        .join("config.toml"),
370                );
371            }
372            paths.push(std::path::PathBuf::from("config.toml"));
373            paths
374        };
375
376        for path in &candidate_paths {
377            match std::fs::read_to_string(path) {
378                Ok(contents) => {
379                    config = toml::from_str(&contents)
380                        .map_err(|e| anyhow::anyhow!("Failed to parse {}: {e}", path.display()))?;
381                    break;
382                }
383                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
384                Err(e) => return Err(anyhow::anyhow!("Failed to read {}: {e}", path.display())),
385            }
386        }
387
388        // Env overrides — SOMA_MCP_* for server config, SOMA_API_* for upstream
389        env_str("SOMA_MCP_HOST", &mut config.mcp.host);
390        env_parse("SOMA_MCP_PORT", &mut config.mcp.port)?;
391        env_str("SOMA_MCP_SERVER_NAME", &mut config.mcp.server_name);
392        env_bool("SOMA_MCP_NO_AUTH", &mut config.mcp.no_auth)?;
393        env_bool("SOMA_NOAUTH", &mut config.mcp.trusted_gateway)?;
394        env_bool(
395            "SOMA_MCP_CONFORMANCE_FIXTURES",
396            &mut config.mcp.conformance_fixtures,
397        )?;
398        env_opt_str("SOMA_MCP_TOKEN", &mut config.mcp.api_token);
399        env_bool(
400            "SOMA_MCP_STATIC_TOKEN_WRITE",
401            &mut config.mcp.static_token_write,
402        )?;
403        env_list("SOMA_MCP_ALLOWED_HOSTS", &mut config.mcp.allowed_hosts);
404        env_list("SOMA_MCP_ALLOWED_ORIGINS", &mut config.mcp.allowed_origins);
405        env_opt_str("SOMA_MCP_PUBLIC_URL", &mut config.mcp.auth.public_url);
406        env_str(
407            "SOMA_MCP_AUTH_ADMIN_EMAIL",
408            &mut config.mcp.auth.admin_email,
409        );
410        env_opt_str(
411            "SOMA_MCP_GOOGLE_CLIENT_ID",
412            &mut config.mcp.auth.google_client_id,
413        );
414        env_opt_str(
415            "SOMA_MCP_GOOGLE_CLIENT_SECRET",
416            &mut config.mcp.auth.google_client_secret,
417        );
418        env_opt_str(
419            "SOMA_MCP_GOOGLE_CALLBACK_PATH",
420            &mut config.mcp.auth.google_callback_path,
421        );
422        env_list("SOMA_MCP_GOOGLE_SCOPES", &mut config.mcp.auth.google_scopes);
423        env_opt_str(
424            "SOMA_MCP_AUTHELIA_ISSUER_URL",
425            &mut config.mcp.auth.authelia_issuer_url,
426        );
427        env_opt_str(
428            "SOMA_MCP_AUTHELIA_CLIENT_ID",
429            &mut config.mcp.auth.authelia_client_id,
430        );
431        env_opt_str(
432            "SOMA_MCP_AUTHELIA_CLIENT_SECRET",
433            &mut config.mcp.auth.authelia_client_secret,
434        );
435        env_opt_str(
436            "SOMA_MCP_AUTHELIA_CALLBACK_PATH",
437            &mut config.mcp.auth.authelia_callback_path,
438        );
439        env_list(
440            "SOMA_MCP_AUTHELIA_SCOPES",
441            &mut config.mcp.auth.authelia_scopes,
442        );
443        env_opt_str(
444            "SOMA_MCP_GITHUB_CLIENT_ID",
445            &mut config.mcp.auth.github_client_id,
446        );
447        env_opt_str(
448            "SOMA_MCP_GITHUB_CLIENT_SECRET",
449            &mut config.mcp.auth.github_client_secret,
450        );
451        env_opt_str(
452            "SOMA_MCP_GITHUB_CALLBACK_PATH",
453            &mut config.mcp.auth.github_callback_path,
454        );
455        env_list("SOMA_MCP_GITHUB_SCOPES", &mut config.mcp.auth.github_scopes);
456        env_opt_str(
457            "SOMA_MCP_AUTH_DEFAULT_PROVIDER",
458            &mut config.mcp.auth.default_provider,
459        );
460        env_opt_str(
461            "SOMA_MCP_AUTH_BOOTSTRAP_SECRET",
462            &mut config.mcp.auth.bootstrap_secret,
463        );
464        env_opt_str(
465            "SOMA_MCP_AUTH_SQLITE_PATH",
466            &mut config.mcp.auth.sqlite_path,
467        );
468        env_opt_str("SOMA_MCP_AUTH_KEY_PATH", &mut config.mcp.auth.key_path);
469        env_opt_parse(
470            "SOMA_MCP_AUTH_ACCESS_TOKEN_TTL_SECS",
471            &mut config.mcp.auth.access_token_ttl_secs,
472        )?;
473        env_opt_parse(
474            "SOMA_MCP_AUTH_REFRESH_TOKEN_TTL_SECS",
475            &mut config.mcp.auth.refresh_token_ttl_secs,
476        )?;
477        env_opt_parse(
478            "SOMA_MCP_AUTH_CODE_TTL_SECS",
479            &mut config.mcp.auth.auth_code_ttl_secs,
480        )?;
481        env_opt_parse(
482            "SOMA_MCP_AUTH_REGISTER_REQUESTS_PER_MINUTE",
483            &mut config.mcp.auth.register_rpm,
484        )?;
485        env_opt_parse(
486            "SOMA_MCP_AUTH_AUTHORIZE_REQUESTS_PER_MINUTE",
487            &mut config.mcp.auth.authorize_rpm,
488        )?;
489        env_opt_parse(
490            "SOMA_MCP_AUTH_MAX_PENDING_OAUTH_STATES",
491            &mut config.mcp.auth.max_pending_oauth_states,
492        )?;
493        env_list(
494            "SOMA_MCP_AUTH_ALLOWED_REDIRECT_URIS",
495            &mut config.mcp.auth.allowed_client_redirect_uris,
496        );
497        env_opt_str(
498            "SOMA_MCP_TOKEN_ENCRYPTION_KEY",
499            &mut config.mcp.auth.token_encryption_key,
500        );
501        if let Ok(v) = std::env::var("SOMA_MCP_AUTH_MODE") {
502            if !v.is_empty() {
503                config.mcp.auth.mode = match v.to_lowercase().as_str() {
504                    "oauth" => AuthMode::OAuth,
505                    "bearer" => AuthMode::Bearer,
506                    other => {
507                        return Err(anyhow::anyhow!(
508                            "invalid SOMA_MCP_AUTH_MODE {:?}: must be \"bearer\" or \"oauth\"",
509                            other
510                        ));
511                    }
512                };
513            }
514        }
515        if let Ok(v) = std::env::var("SOMA_MCP_TRACE_HEADERS") {
516            if !v.is_empty() {
517                config.mcp.trace_headers = match v.to_lowercase().as_str() {
518                    "off" => TraceHeaderMode::Off,
519                    "trusted" => TraceHeaderMode::Trusted,
520                    "trusted-with-baggage" => TraceHeaderMode::TrustedWithBaggage,
521                    other => {
522                        return Err(anyhow::anyhow!(
523                            "invalid SOMA_MCP_TRACE_HEADERS {:?}: must be \"off\", \"trusted\", \
524                             or \"trusted-with-baggage\"",
525                            other
526                        ));
527                    }
528                };
529            }
530        }
531
532        // Upstream service config
533        env_str("SOMA_API_URL", &mut config.soma.api_url);
534        env_str("SOMA_API_KEY", &mut config.soma.api_key);
535        if let Ok(v) = std::env::var("SOMA_RUNTIME_MODE") {
536            if !v.is_empty() {
537                config.soma.runtime_mode = match v.to_lowercase().as_str() {
538                    "auto" => RuntimeMode::Auto,
539                    "local" => RuntimeMode::Local,
540                    "remote" | "api" => RuntimeMode::Remote,
541                    other => {
542                        return Err(anyhow::anyhow!(
543                            "invalid SOMA_RUNTIME_MODE {:?}: must be \"auto\", \"local\", or \"remote\"",
544                            other
545                        ));
546                    }
547                };
548            }
549        }
550
551        Ok(config)
552    }
553}
554
555// ── env helpers ───────────────────────────────────────────────────────────────
556
557fn env_str(key: &str, target: &mut String) {
558    if let Ok(v) = std::env::var(key) {
559        if !v.is_empty() {
560            *target = v;
561        }
562    }
563}
564
565fn env_opt_str(key: &str, target: &mut Option<String>) {
566    if let Ok(v) = std::env::var(key) {
567        if !v.is_empty() {
568            *target = Some(v);
569        }
570    }
571}
572
573fn env_parse<T: std::str::FromStr>(key: &str, target: &mut T) -> anyhow::Result<()> {
574    if let Ok(v) = std::env::var(key) {
575        if !v.is_empty() {
576            *target = v
577                .parse()
578                .map_err(|_| anyhow::anyhow!("{key}: invalid value {v:?}"))?;
579        }
580    }
581    Ok(())
582}
583
584fn env_opt_parse<T: std::str::FromStr>(key: &str, target: &mut Option<T>) -> anyhow::Result<()> {
585    if let Ok(v) = std::env::var(key) {
586        if !v.is_empty() {
587            *target = Some(
588                v.parse()
589                    .map_err(|_| anyhow::anyhow!("{key}: invalid value {v:?}"))?,
590            );
591        }
592    }
593    Ok(())
594}
595
596fn env_bool(key: &str, target: &mut bool) -> anyhow::Result<()> {
597    if let Ok(v) = std::env::var(key) {
598        match v.to_lowercase().as_str() {
599            "1" | "true" | "yes" => *target = true,
600            "0" | "false" | "no" => *target = false,
601            other => anyhow::bail!("{key}: expected bool, got {other:?}"),
602        }
603    }
604    Ok(())
605}
606
607fn env_list(key: &str, target: &mut Vec<String>) {
608    if let Ok(v) = std::env::var(key) {
609        let items: Vec<String> = v
610            .split(',')
611            .map(|s| s.trim().to_string())
612            .filter(|s| !s.is_empty())
613            .collect();
614        if !items.is_empty() {
615            *target = items;
616        }
617    }
618}
619
620#[cfg(test)]
621#[path = "config_tests.rs"]
622mod tests;