Skip to main content

platform_core/
config.rs

1use crate::error::{AppError, AppResult, ErrorDetail};
2use serde::de::DeserializeOwned;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6pub const DEFAULT_LINKED_MODULE_PROFILE: &str = "demo";
7pub const LENSO_COMPOSITION_PROFILE_ENV: &str = "LENSO_COMPOSITION_PROFILE";
8
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct AppConfig {
11    pub service: ServiceConfig,
12    pub database: DatabaseConfig,
13    #[serde(default)]
14    pub redis: RedisConfig,
15    pub http: HttpConfig,
16    pub telemetry: TelemetryConfig,
17    pub auth: AuthConfig,
18    #[serde(default)]
19    pub console: ConsoleConfig,
20    #[serde(default)]
21    pub module_sources: ModuleSourcesConfig,
22    #[serde(default)]
23    pub modules: BTreeMap<String, ModuleConfig>,
24}
25
26impl AppConfig {
27    pub fn from_env() -> Self {
28        Self::try_from_env().expect("valid Lenso application configuration")
29    }
30
31    pub fn try_from_env() -> AppResult<Self> {
32        let _ = dotenvy::dotenv();
33        let service = ServiceConfig::default();
34        Ok(Self {
35            module_sources: ModuleSourcesConfig::try_from_env_for_environment(
36                &service.environment,
37            )?,
38            service,
39            database: DatabaseConfig::from_env(),
40            redis: RedisConfig::from_env(),
41            http: HttpConfig::default(),
42            telemetry: TelemetryConfig::default(),
43            auth: AuthConfig::default(),
44            console: ConsoleConfig::default(),
45            modules: module_configs_from_env(),
46        })
47    }
48
49    pub fn module_local_config<T: DeserializeOwned>(&self, module_name: &str) -> AppResult<T> {
50        let values = self
51            .modules
52            .get(module_name)
53            .map(|config| config.values.clone())
54            .unwrap_or_default();
55        decode_module_local_config(module_name, &values)
56    }
57}
58
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct ServiceConfig {
61    pub name: String,
62    pub environment: String,
63}
64
65impl Default for ServiceConfig {
66    fn default() -> Self {
67        Self {
68            name: std::env::var("SERVICE_NAME").unwrap_or_else(|_| "lenso".to_owned()),
69            environment: std::env::var("APP_ENV").unwrap_or_else(|_| "local".to_owned()),
70        }
71    }
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct DatabaseConfig {
76    pub url: String,
77    pub max_connections: u32,
78}
79
80impl DatabaseConfig {
81    fn from_env() -> Self {
82        Self {
83            url: std::env::var("DATABASE_URL")
84                .unwrap_or_else(|_| "postgres://lenso:lenso@localhost:5432/lenso".to_owned()),
85            max_connections: std::env::var("DATABASE_MAX_CONNECTIONS")
86                .ok()
87                .and_then(|value| value.parse().ok())
88                .unwrap_or(10),
89        }
90    }
91}
92
93#[derive(Debug, Clone, Default, Deserialize, Serialize)]
94pub struct RedisConfig {
95    pub url: Option<String>,
96}
97
98impl RedisConfig {
99    fn from_env() -> Self {
100        Self::from_url_value(std::env::var("REDIS_URL").ok().as_deref())
101    }
102
103    #[must_use]
104    pub fn from_url_value(value: Option<&str>) -> Self {
105        Self {
106            url: value
107                .map(str::trim)
108                .filter(|value| !value.is_empty())
109                .map(ToOwned::to_owned),
110        }
111    }
112}
113
114#[derive(Debug, Clone, Deserialize, Serialize)]
115pub struct HttpConfig {
116    pub host: String,
117    pub port: u16,
118    /// Origins permitted by CORS. Defaults to the local Runtime Console dev
119    /// ports; override with `CORS_ALLOWED_ORIGINS` (comma-separated).
120    #[serde(default)]
121    pub cors_allowed_origins: Vec<String>,
122}
123
124impl Default for HttpConfig {
125    fn default() -> Self {
126        Self {
127            host: std::env::var("HTTP_HOST").unwrap_or_else(|_| "0.0.0.0".to_owned()),
128            port: std::env::var("HTTP_PORT")
129                .ok()
130                .and_then(|value| value.parse().ok())
131                .unwrap_or(3000),
132            cors_allowed_origins: std::env::var("CORS_ALLOWED_ORIGINS").map_or_else(
133                |_| default_cors_allowed_origins(),
134                |value| parse_cors_allowed_origins(&value),
135            ),
136        }
137    }
138}
139
140fn default_cors_allowed_origins() -> Vec<String> {
141    (5173..=5177)
142        .map(|port| format!("http://localhost:{port}"))
143        .collect()
144}
145
146/// Parse a comma-separated `CORS_ALLOWED_ORIGINS` value into trimmed, non-empty
147/// origins.
148#[must_use]
149pub fn parse_cors_allowed_origins(value: &str) -> Vec<String> {
150    value
151        .split(',')
152        .map(str::trim)
153        .filter(|origin| !origin.is_empty())
154        .map(ToOwned::to_owned)
155        .collect()
156}
157
158#[derive(Debug, Clone, Deserialize, Serialize)]
159pub struct TelemetryConfig {
160    pub log_level: String,
161    #[serde(default)]
162    pub log_format: LogFormat,
163    pub otlp_endpoint: Option<String>,
164}
165
166impl Default for TelemetryConfig {
167    fn default() -> Self {
168        Self {
169            log_level: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_owned()),
170            log_format: std::env::var("LOG_FORMAT")
171                .ok()
172                .and_then(|value| LogFormat::from_env_value(&value))
173                .unwrap_or_default(),
174            otlp_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
175        }
176    }
177}
178
179#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
180#[serde(rename_all = "snake_case")]
181pub enum LogFormat {
182    #[default]
183    Compact,
184    Json,
185}
186
187impl LogFormat {
188    pub fn from_env_value(value: &str) -> Option<Self> {
189        match value.trim().to_ascii_lowercase().as_str() {
190            "compact" | "terminal" | "text" => Some(Self::Compact),
191            "json" => Some(Self::Json),
192            _ => None,
193        }
194    }
195}
196
197#[derive(Debug, Clone, Default, Deserialize, Serialize)]
198pub struct AuthConfig {
199    pub issuer: Option<String>,
200    pub audience: Option<String>,
201}
202
203#[derive(Debug, Clone, Deserialize, Serialize)]
204pub struct ConsoleConfig {
205    pub dist_dir: String,
206    pub extensions_dir: String,
207}
208
209impl Default for ConsoleConfig {
210    fn default() -> Self {
211        Self {
212            dist_dir: std::env::var("LENSO_CONSOLE_DIST_DIR")
213                .unwrap_or_else(|_| ".lenso/console/dist".to_owned()),
214            extensions_dir: std::env::var("LENSO_CONSOLE_EXTENSIONS_DIR")
215                .unwrap_or_else(|_| ".lenso/console/extensions".to_owned()),
216        }
217    }
218}
219
220#[derive(Debug, Clone, Default, Deserialize, Serialize)]
221pub struct ModuleConfig {
222    #[serde(default)]
223    pub enabled: Option<bool>,
224    #[serde(flatten)]
225    pub values: BTreeMap<String, serde_json::Value>,
226}
227
228impl ModuleConfig {
229    #[must_use]
230    pub fn is_enabled(&self) -> bool {
231        self.enabled.unwrap_or(true)
232    }
233
234    pub fn local_config<T: DeserializeOwned>(&self, module_name: &str) -> AppResult<T> {
235        decode_module_local_config(module_name, &self.values)
236    }
237}
238
239fn module_configs_from_env() -> BTreeMap<String, ModuleConfig> {
240    let mut configs = BTreeMap::new();
241    for (key, value) in std::env::vars() {
242        if let Some((module_name, update)) = module_config_from_env_entry(&key, &value) {
243            merge_module_config(&mut configs, module_name, update);
244        }
245    }
246    configs
247}
248
249fn module_config_from_env_entry(key: &str, value: &str) -> Option<(String, ModuleConfig)> {
250    let rest = key.strip_prefix("LENSO_MODULE_")?;
251    if !rest.contains("__")
252        && let Some(module_name) = rest.strip_suffix("_ENABLED").and_then(module_env_name)
253    {
254        return Some((
255            module_name,
256            ModuleConfig {
257                enabled: Some(parse_bool_env(value)?),
258                values: BTreeMap::new(),
259            },
260        ));
261    }
262
263    let (module_name, config_key) = rest.split_once("__")?;
264    let module_name = module_env_name(module_name)?;
265    let config_key = module_value_env_key(config_key)?;
266    let mut values = BTreeMap::new();
267    values.insert(config_key, parse_module_env_value(value));
268    Some((
269        module_name,
270        ModuleConfig {
271            enabled: None,
272            values,
273        },
274    ))
275}
276
277fn merge_module_config(
278    configs: &mut BTreeMap<String, ModuleConfig>,
279    module_name: String,
280    update: ModuleConfig,
281) {
282    let config = configs.entry(module_name).or_default();
283    if update.enabled.is_some() {
284        config.enabled = update.enabled;
285    }
286    config.values.extend(update.values);
287}
288
289fn module_env_name(value: &str) -> Option<String> {
290    let name = value
291        .trim_matches('_')
292        .to_ascii_lowercase()
293        .replace('_', "-");
294    (!name.is_empty()).then_some(name)
295}
296
297fn module_value_env_key(value: &str) -> Option<String> {
298    let key = value.trim_matches('_').to_ascii_lowercase();
299    (!key.is_empty()).then_some(key)
300}
301
302fn parse_module_env_value(value: &str) -> serde_json::Value {
303    let trimmed = value.trim();
304    serde_json::from_str(trimmed).unwrap_or_else(|_| serde_json::json!(trimmed))
305}
306
307fn decode_module_local_config<T: DeserializeOwned>(
308    module_name: &str,
309    values: &BTreeMap<String, serde_json::Value>,
310) -> AppResult<T> {
311    let object = values
312        .iter()
313        .map(|(key, value)| (key.clone(), value.clone()))
314        .collect();
315    serde_json::from_value(serde_json::Value::Object(object)).map_err(|source| {
316        AppError::validation(
317            "Invalid module local configuration",
318            vec![ErrorDetail {
319                field: Some(format!("modules.{module_name}")),
320                reason: source.to_string(),
321            }],
322        )
323    })
324}
325
326fn parse_bool_env(value: &str) -> Option<bool> {
327    match value.trim().to_ascii_lowercase().as_str() {
328        "1" | "true" | "yes" | "on" => Some(true),
329        "0" | "false" | "no" | "off" => Some(false),
330        _ => None,
331    }
332}
333
334#[derive(Debug, Clone, Deserialize, Serialize)]
335pub struct ModuleSourcesConfig {
336    #[serde(default = "default_linked_module_profile")]
337    pub linked_profile: String,
338    #[serde(default)]
339    pub remote: Vec<RemoteModuleSourceConfig>,
340}
341
342impl ModuleSourcesConfig {
343    fn try_from_env_for_environment(environment: &str) -> AppResult<Self> {
344        Ok(Self {
345            linked_profile: linked_module_profile_from_env_value(
346                std::env::var(LENSO_COMPOSITION_PROFILE_ENV).ok().as_deref(),
347                environment,
348            )?,
349            remote: remote_module_sources_from_env(),
350        })
351    }
352}
353
354impl Default for ModuleSourcesConfig {
355    fn default() -> Self {
356        Self {
357            linked_profile: default_linked_module_profile(),
358            remote: Vec::new(),
359        }
360    }
361}
362
363fn default_linked_module_profile() -> String {
364    DEFAULT_LINKED_MODULE_PROFILE.to_owned()
365}
366
367fn linked_module_profile_from_env_value(
368    value: Option<&str>,
369    environment: &str,
370) -> AppResult<String> {
371    let Some(profile) = value.map(str::trim).filter(|value| !value.is_empty()) else {
372        if is_local_development_environment(environment) {
373            return Ok(DEFAULT_LINKED_MODULE_PROFILE.to_owned());
374        }
375        return Err(AppError::validation(
376            "Lenso composition profile is required outside local development",
377            vec![ErrorDetail {
378                field: Some(LENSO_COMPOSITION_PROFILE_ENV.to_owned()),
379                reason: format!(
380                    "set {LENSO_COMPOSITION_PROFILE_ENV}=core or {LENSO_COMPOSITION_PROFILE_ENV}=demo when APP_ENV is `{}`",
381                    environment.trim()
382                ),
383            }],
384        ));
385    };
386
387    Ok(profile.to_owned())
388}
389
390#[must_use]
391pub fn is_local_development_environment(environment: &str) -> bool {
392    matches!(
393        environment.trim().to_ascii_lowercase().as_str(),
394        "local" | "dev" | "development" | "test"
395    )
396}
397
398#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
399pub struct RemoteModuleSourceConfig {
400    pub name: String,
401    pub base_url: String,
402    pub auth_token_env: Option<String>,
403    pub timeout_ms: u64,
404}
405
406fn remote_module_sources_from_env() -> Vec<RemoteModuleSourceConfig> {
407    let Some(raw) = std::env::var("REMOTE_MODULES").ok() else {
408        return Vec::new();
409    };
410
411    raw.split(',')
412        .filter_map(|entry| parse_remote_module_source(entry.trim()))
413        .collect()
414}
415
416fn parse_remote_module_source(entry: &str) -> Option<RemoteModuleSourceConfig> {
417    if entry.is_empty() {
418        return None;
419    }
420    let (name, base_url) = entry.split_once('=')?;
421    let name = name.trim();
422    let base_url = base_url.trim();
423    if name.is_empty() || base_url.is_empty() {
424        return None;
425    }
426
427    let env_prefix = name.replace('-', "_").to_ascii_uppercase();
428    let token_env = format!("REMOTE_MODULE_{env_prefix}_TOKEN");
429    let timeout_env = format!("REMOTE_MODULE_{env_prefix}_TIMEOUT_MS");
430
431    Some(RemoteModuleSourceConfig {
432        name: name.to_owned(),
433        base_url: base_url.trim_end_matches('/').to_owned(),
434        auth_token_env: Some(token_env),
435        timeout_ms: std::env::var(timeout_env)
436            .ok()
437            .and_then(|value| value.parse().ok())
438            .unwrap_or(5_000),
439    })
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use serde::Deserialize;
446
447    #[test]
448    fn module_sources_default_to_demo_linked_profile() {
449        let config = ModuleSourcesConfig::default();
450
451        assert_eq!(config.linked_profile, DEFAULT_LINKED_MODULE_PROFILE);
452        assert!(config.remote.is_empty());
453    }
454
455    #[test]
456    fn linked_module_profile_from_env_value_trims_empty_to_default() {
457        assert_eq!(
458            linked_module_profile_from_env_value(None, "local").expect("local default"),
459            DEFAULT_LINKED_MODULE_PROFILE
460        );
461        assert_eq!(
462            linked_module_profile_from_env_value(Some("  "), "development")
463                .expect("development default"),
464            DEFAULT_LINKED_MODULE_PROFILE
465        );
466        assert_eq!(
467            linked_module_profile_from_env_value(Some("core"), "production")
468                .expect("explicit profile"),
469            "core"
470        );
471        assert_eq!(
472            linked_module_profile_from_env_value(Some(" demo "), "production")
473                .expect("explicit profile"),
474            "demo"
475        );
476    }
477
478    #[test]
479    fn linked_module_profile_from_env_value_requires_explicit_profile_outside_local() {
480        let error = linked_module_profile_from_env_value(None, "production")
481            .expect_err("production requires explicit linked profile");
482
483        assert_eq!(error.code, crate::ErrorCode::Validation);
484        assert_eq!(
485            error.details[0].field.as_deref(),
486            Some(LENSO_COMPOSITION_PROFILE_ENV)
487        );
488        assert!(
489            error.details[0]
490                .reason
491                .contains("LENSO_COMPOSITION_PROFILE=core")
492        );
493    }
494
495    #[test]
496    fn module_sources_deserialize_missing_linked_profile_to_default() {
497        let config: ModuleSourcesConfig =
498            serde_json::from_value(serde_json::json!({ "remote": [] }))
499                .expect("module sources deserialize");
500
501        assert_eq!(config.linked_profile, DEFAULT_LINKED_MODULE_PROFILE);
502        assert!(config.remote.is_empty());
503    }
504
505    #[test]
506    fn parses_remote_module_source_entry() {
507        let config = parse_remote_module_source("remote-crm=http://localhost:4100/lenso/module/v1")
508            .expect("parse remote source");
509        assert_eq!(config.name, "remote-crm");
510        assert_eq!(config.base_url, "http://localhost:4100/lenso/module/v1");
511        assert_eq!(
512            config.auth_token_env.as_deref(),
513            Some("REMOTE_MODULE_REMOTE_CRM_TOKEN")
514        );
515        assert_eq!(config.timeout_ms, 5_000);
516    }
517
518    #[test]
519    fn ignores_malformed_remote_module_source_entry() {
520        assert!(parse_remote_module_source("").is_none());
521        assert!(parse_remote_module_source("missing-url").is_none());
522        assert!(parse_remote_module_source("=http://localhost:4100").is_none());
523    }
524
525    #[test]
526    fn module_config_from_env_entry_parses_enabled_override() {
527        let (name, config) =
528            module_config_from_env_entry("LENSO_MODULE_AUTH_PASSWORD_ENABLED", "false")
529                .expect("module enabled env should parse");
530
531        assert_eq!(name, "auth-password");
532        assert_eq!(config.enabled, Some(false));
533    }
534
535    #[test]
536    fn module_config_from_env_entry_parses_local_values() {
537        let (name, config) =
538            module_config_from_env_entry("LENSO_MODULE_AUTH_PASSWORD__JWT_TTL_HOURS", "12")
539                .expect("module local value env should parse");
540
541        assert_eq!(name, "auth-password");
542        assert_eq!(
543            config.values.get("jwt_ttl_hours"),
544            Some(&serde_json::json!(12))
545        );
546
547        let (_, config) =
548            module_config_from_env_entry("LENSO_MODULE_AUTH__PUBLIC_URL", "https://example.test")
549                .expect("module string value env should parse");
550        assert_eq!(
551            config.values.get("public_url"),
552            Some(&serde_json::json!("https://example.test"))
553        );
554
555        let (_, config) = module_config_from_env_entry("LENSO_MODULE_AUTH__ENABLED", "\"local\"")
556            .expect("module local enabled key should parse as a value");
557        assert_eq!(
558            config.values.get("enabled"),
559            Some(&serde_json::json!("local"))
560        );
561        assert_eq!(config.enabled, None);
562    }
563
564    #[test]
565    fn merge_module_config_keeps_enabled_and_values() {
566        let mut configs = BTreeMap::new();
567        let (_, enabled) = module_config_from_env_entry("LENSO_MODULE_AUTH_ENABLED", "false")
568            .expect("enabled parses");
569        let (_, local) = module_config_from_env_entry("LENSO_MODULE_AUTH__PUBLIC_URL", "\"/auth\"")
570            .expect("local value parses");
571
572        merge_module_config(&mut configs, "auth".to_owned(), enabled);
573        merge_module_config(&mut configs, "auth".to_owned(), local);
574
575        let config = configs.get("auth").expect("merged module config");
576        assert_eq!(config.enabled, Some(false));
577        assert_eq!(
578            config.values.get("public_url"),
579            Some(&serde_json::json!("/auth"))
580        );
581    }
582
583    #[derive(Debug, Deserialize, PartialEq)]
584    struct DemoModuleLocalConfig {
585        public_url: String,
586        #[serde(default)]
587        ttl_hours: u64,
588    }
589
590    #[test]
591    fn module_config_decodes_local_values() {
592        let (_, config) = module_config_from_env_entry("LENSO_MODULE_DEMO__PUBLIC_URL", "/demo")
593            .expect("local value parses");
594
595        let decoded: DemoModuleLocalConfig = config.local_config("demo").expect("decode config");
596
597        assert_eq!(
598            decoded,
599            DemoModuleLocalConfig {
600                public_url: "/demo".to_owned(),
601                ttl_hours: 0,
602            }
603        );
604    }
605
606    #[test]
607    fn redis_config_treats_empty_url_as_disabled() {
608        assert!(RedisConfig::from_url_value(None).url.is_none());
609        assert!(RedisConfig::from_url_value(Some("  ")).url.is_none());
610        assert_eq!(
611            RedisConfig::from_url_value(Some(" redis://localhost:6379/0 "))
612                .url
613                .as_deref(),
614            Some("redis://localhost:6379/0")
615        );
616    }
617}