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