Skip to main content

platform_core/
config.rs

1use crate::error::{AppError, AppResult, ErrorDetail};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4
5pub const DEFAULT_LINKED_MODULE_PROFILE: &str = "demo";
6pub const LENSO_COMPOSITION_PROFILE_ENV: &str = "LENSO_COMPOSITION_PROFILE";
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
9pub struct AppConfig {
10    pub service: ServiceConfig,
11    pub database: DatabaseConfig,
12    pub http: HttpConfig,
13    pub telemetry: TelemetryConfig,
14    pub auth: AuthConfig,
15    #[serde(default)]
16    pub console: ConsoleConfig,
17    #[serde(default)]
18    pub module_sources: ModuleSourcesConfig,
19    #[serde(default)]
20    pub modules: BTreeMap<String, ModuleConfig>,
21}
22
23impl AppConfig {
24    pub fn from_env() -> Self {
25        Self::try_from_env().expect("valid Lenso application configuration")
26    }
27
28    pub fn try_from_env() -> AppResult<Self> {
29        let _ = dotenvy::dotenv();
30        let service = ServiceConfig::default();
31        Ok(Self {
32            module_sources: ModuleSourcesConfig::try_from_env_for_environment(
33                &service.environment,
34            )?,
35            service,
36            database: DatabaseConfig::from_env(),
37            http: HttpConfig::default(),
38            telemetry: TelemetryConfig::default(),
39            auth: AuthConfig::default(),
40            console: ConsoleConfig::default(),
41            modules: module_configs_from_env(),
42        })
43    }
44}
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct ServiceConfig {
48    pub name: String,
49    pub environment: String,
50}
51
52impl Default for ServiceConfig {
53    fn default() -> Self {
54        Self {
55            name: std::env::var("SERVICE_NAME").unwrap_or_else(|_| "lenso".to_owned()),
56            environment: std::env::var("APP_ENV").unwrap_or_else(|_| "local".to_owned()),
57        }
58    }
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct DatabaseConfig {
63    pub url: String,
64    pub max_connections: u32,
65}
66
67impl DatabaseConfig {
68    fn from_env() -> Self {
69        Self {
70            url: std::env::var("DATABASE_URL")
71                .unwrap_or_else(|_| "postgres://lenso:lenso@localhost:5432/lenso".to_owned()),
72            max_connections: std::env::var("DATABASE_MAX_CONNECTIONS")
73                .ok()
74                .and_then(|value| value.parse().ok())
75                .unwrap_or(10),
76        }
77    }
78}
79
80#[derive(Debug, Clone, Deserialize, Serialize)]
81pub struct HttpConfig {
82    pub host: String,
83    pub port: u16,
84    /// Origins permitted by CORS. Defaults to the local Runtime Console dev
85    /// ports; override with `CORS_ALLOWED_ORIGINS` (comma-separated).
86    #[serde(default)]
87    pub cors_allowed_origins: Vec<String>,
88}
89
90impl Default for HttpConfig {
91    fn default() -> Self {
92        Self {
93            host: std::env::var("HTTP_HOST").unwrap_or_else(|_| "0.0.0.0".to_owned()),
94            port: std::env::var("HTTP_PORT")
95                .ok()
96                .and_then(|value| value.parse().ok())
97                .unwrap_or(3000),
98            cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS").map_or_else(
99                |_| default_cors_allowed_origins(),
100                |value| parse_cors_allowed_origins(&value),
101            ),
102        }
103    }
104}
105
106fn default_cors_allowed_origins() -> Vec<String> {
107    (5173..=5177)
108        .map(|port| format!("http://localhost:{port}"))
109        .collect()
110}
111
112/// Parse a comma-separated `CORS_ALLOWED_ORIGINS` value into trimmed, non-empty
113/// origins.
114#[must_use]
115pub fn parse_cors_allowed_origins(value: &str) -> Vec<String> {
116    value
117        .split(',')
118        .map(str::trim)
119        .filter(|origin| !origin.is_empty())
120        .map(ToOwned::to_owned)
121        .collect()
122}
123
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct TelemetryConfig {
126    pub log_level: String,
127    #[serde(default)]
128    pub log_format: LogFormat,
129    pub otlp_endpoint: Option<String>,
130}
131
132impl Default for TelemetryConfig {
133    fn default() -> Self {
134        Self {
135            log_level: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_owned()),
136            log_format: std::env::var("LOG_FORMAT")
137                .ok()
138                .and_then(|value| LogFormat::from_env_value(&value))
139                .unwrap_or_default(),
140            otlp_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
141        }
142    }
143}
144
145#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
146#[serde(rename_all = "snake_case")]
147pub enum LogFormat {
148    #[default]
149    Compact,
150    Json,
151}
152
153impl LogFormat {
154    pub fn from_env_value(value: &str) -> Option<Self> {
155        match value.trim().to_ascii_lowercase().as_str() {
156            "compact" | "terminal" | "text" => Some(Self::Compact),
157            "json" => Some(Self::Json),
158            _ => None,
159        }
160    }
161}
162
163#[derive(Debug, Clone, Default, Deserialize, Serialize)]
164pub struct AuthConfig {
165    pub issuer: Option<String>,
166    pub audience: Option<String>,
167}
168
169#[derive(Debug, Clone, Deserialize, Serialize)]
170pub struct ConsoleConfig {
171    pub dist_dir: String,
172    pub extensions_dir: String,
173}
174
175impl Default for ConsoleConfig {
176    fn default() -> Self {
177        Self {
178            dist_dir: std::env::var("LENSO_CONSOLE_DIST_DIR")
179                .unwrap_or_else(|_| ".lenso/console/dist".to_owned()),
180            extensions_dir: std::env::var("LENSO_CONSOLE_EXTENSIONS_DIR")
181                .unwrap_or_else(|_| ".lenso/console/extensions".to_owned()),
182        }
183    }
184}
185
186#[derive(Debug, Clone, Default, Deserialize, Serialize)]
187pub struct ModuleConfig {
188    #[serde(default)]
189    pub enabled: Option<bool>,
190    #[serde(flatten)]
191    pub values: BTreeMap<String, serde_json::Value>,
192}
193
194impl ModuleConfig {
195    #[must_use]
196    pub fn is_enabled(&self) -> bool {
197        self.enabled.unwrap_or(true)
198    }
199}
200
201fn module_configs_from_env() -> BTreeMap<String, ModuleConfig> {
202    std::env::vars()
203        .filter_map(|(key, value)| module_config_from_env_entry(&key, &value))
204        .collect()
205}
206
207fn module_config_from_env_entry(key: &str, value: &str) -> Option<(String, ModuleConfig)> {
208    let module_name = key
209        .strip_prefix("LENSO_MODULE_")?
210        .strip_suffix("_ENABLED")?
211        .to_ascii_lowercase()
212        .replace('_', "-");
213    if module_name.is_empty() {
214        return None;
215    }
216    let enabled = parse_bool_env(value)?;
217    Some((
218        module_name,
219        ModuleConfig {
220            enabled: Some(enabled),
221            values: BTreeMap::new(),
222        },
223    ))
224}
225
226fn parse_bool_env(value: &str) -> Option<bool> {
227    match value.trim().to_ascii_lowercase().as_str() {
228        "1" | "true" | "yes" | "on" => Some(true),
229        "0" | "false" | "no" | "off" => Some(false),
230        _ => None,
231    }
232}
233
234#[derive(Debug, Clone, Deserialize, Serialize)]
235pub struct ModuleSourcesConfig {
236    #[serde(default = "default_linked_module_profile")]
237    pub linked_profile: String,
238    #[serde(default)]
239    pub remote: Vec<RemoteModuleSourceConfig>,
240}
241
242impl ModuleSourcesConfig {
243    fn try_from_env_for_environment(environment: &str) -> AppResult<Self> {
244        Ok(Self {
245            linked_profile: linked_module_profile_from_env_value(
246                std::env::var(LENSO_COMPOSITION_PROFILE_ENV).ok().as_deref(),
247                environment,
248            )?,
249            remote: remote_module_sources_from_env(),
250        })
251    }
252}
253
254impl Default for ModuleSourcesConfig {
255    fn default() -> Self {
256        Self {
257            linked_profile: default_linked_module_profile(),
258            remote: Vec::new(),
259        }
260    }
261}
262
263fn default_linked_module_profile() -> String {
264    DEFAULT_LINKED_MODULE_PROFILE.to_owned()
265}
266
267fn linked_module_profile_from_env_value(
268    value: Option<&str>,
269    environment: &str,
270) -> AppResult<String> {
271    let Some(profile) = value.map(str::trim).filter(|value| !value.is_empty()) else {
272        if is_local_development_environment(environment) {
273            return Ok(DEFAULT_LINKED_MODULE_PROFILE.to_owned());
274        }
275        return Err(AppError::validation(
276            "Lenso composition profile is required outside local development",
277            vec![ErrorDetail {
278                field: Some(LENSO_COMPOSITION_PROFILE_ENV.to_owned()),
279                reason: format!(
280                    "set {LENSO_COMPOSITION_PROFILE_ENV}=core or {LENSO_COMPOSITION_PROFILE_ENV}=demo when APP_ENV is `{}`",
281                    environment.trim()
282                ),
283            }],
284        ));
285    };
286
287    Ok(profile.to_owned())
288}
289
290#[must_use]
291pub fn is_local_development_environment(environment: &str) -> bool {
292    matches!(
293        environment.trim().to_ascii_lowercase().as_str(),
294        "local" | "dev" | "development" | "test"
295    )
296}
297
298#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
299pub struct RemoteModuleSourceConfig {
300    pub name: String,
301    pub base_url: String,
302    pub auth_token_env: Option<String>,
303    pub timeout_ms: u64,
304}
305
306fn remote_module_sources_from_env() -> Vec<RemoteModuleSourceConfig> {
307    let Some(raw) = std::env::var("REMOTE_MODULES").ok() else {
308        return Vec::new();
309    };
310
311    raw.split(',')
312        .filter_map(|entry| parse_remote_module_source(entry.trim()))
313        .collect()
314}
315
316fn parse_remote_module_source(entry: &str) -> Option<RemoteModuleSourceConfig> {
317    if entry.is_empty() {
318        return None;
319    }
320    let (name, base_url) = entry.split_once('=')?;
321    let name = name.trim();
322    let base_url = base_url.trim();
323    if name.is_empty() || base_url.is_empty() {
324        return None;
325    }
326
327    let env_prefix = name.replace('-', "_").to_ascii_uppercase();
328    let token_env = format!("REMOTE_MODULE_{env_prefix}_TOKEN");
329    let timeout_env = format!("REMOTE_MODULE_{env_prefix}_TIMEOUT_MS");
330
331    Some(RemoteModuleSourceConfig {
332        name: name.to_owned(),
333        base_url: base_url.trim_end_matches('/').to_owned(),
334        auth_token_env: Some(token_env),
335        timeout_ms: std::env::var(timeout_env)
336            .ok()
337            .and_then(|value| value.parse().ok())
338            .unwrap_or(5_000),
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn module_sources_default_to_demo_linked_profile() {
348        let config = ModuleSourcesConfig::default();
349
350        assert_eq!(config.linked_profile, DEFAULT_LINKED_MODULE_PROFILE);
351        assert!(config.remote.is_empty());
352    }
353
354    #[test]
355    fn linked_module_profile_from_env_value_trims_empty_to_default() {
356        assert_eq!(
357            linked_module_profile_from_env_value(None, "local").expect("local default"),
358            DEFAULT_LINKED_MODULE_PROFILE
359        );
360        assert_eq!(
361            linked_module_profile_from_env_value(Some("  "), "development")
362                .expect("development default"),
363            DEFAULT_LINKED_MODULE_PROFILE
364        );
365        assert_eq!(
366            linked_module_profile_from_env_value(Some("core"), "production")
367                .expect("explicit profile"),
368            "core"
369        );
370        assert_eq!(
371            linked_module_profile_from_env_value(Some(" demo "), "production")
372                .expect("explicit profile"),
373            "demo"
374        );
375    }
376
377    #[test]
378    fn linked_module_profile_from_env_value_requires_explicit_profile_outside_local() {
379        let error = linked_module_profile_from_env_value(None, "production")
380            .expect_err("production requires explicit linked profile");
381
382        assert_eq!(error.code, crate::ErrorCode::Validation);
383        assert_eq!(
384            error.details[0].field.as_deref(),
385            Some(LENSO_COMPOSITION_PROFILE_ENV)
386        );
387        assert!(
388            error.details[0]
389                .reason
390                .contains("LENSO_COMPOSITION_PROFILE=core")
391        );
392    }
393
394    #[test]
395    fn module_sources_deserialize_missing_linked_profile_to_default() {
396        let config: ModuleSourcesConfig =
397            serde_json::from_value(serde_json::json!({ "remote": [] }))
398                .expect("module sources deserialize");
399
400        assert_eq!(config.linked_profile, DEFAULT_LINKED_MODULE_PROFILE);
401        assert!(config.remote.is_empty());
402    }
403
404    #[test]
405    fn parses_remote_module_source_entry() {
406        let config = parse_remote_module_source("remote-crm=http://localhost:4100/lenso/module/v1")
407            .expect("parse remote source");
408        assert_eq!(config.name, "remote-crm");
409        assert_eq!(config.base_url, "http://localhost:4100/lenso/module/v1");
410        assert_eq!(
411            config.auth_token_env.as_deref(),
412            Some("REMOTE_MODULE_REMOTE_CRM_TOKEN")
413        );
414        assert_eq!(config.timeout_ms, 5_000);
415    }
416
417    #[test]
418    fn ignores_malformed_remote_module_source_entry() {
419        assert!(parse_remote_module_source("").is_none());
420        assert!(parse_remote_module_source("missing-url").is_none());
421        assert!(parse_remote_module_source("=http://localhost:4100").is_none());
422    }
423
424    #[test]
425    fn module_config_from_env_entry_parses_enabled_override() {
426        let (name, config) =
427            module_config_from_env_entry("LENSO_MODULE_AUTH_PASSWORD_ENABLED", "false")
428                .expect("module enabled env should parse");
429
430        assert_eq!(name, "auth-password");
431        assert_eq!(config.enabled, Some(false));
432    }
433}