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 #[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#[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}