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