1use std::path::Path;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::fs;
16use parking_lot::RwLock;
17use tracing::info;
18
19pub mod env;
20pub use env::{detect_profile, parse_args, merge_env_overrides};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct AppConfig {
25 #[serde(default = "default_app_name")]
27 pub app_name: String,
28
29 #[serde(default = "default_profile")]
31 pub profile: String,
32
33 #[serde(default)]
35 pub server: ServerConfig,
36
37 #[serde(default)]
39 pub log: LogConfig,
40
41 #[serde(default)]
43 pub database: DatabaseConfig,
44
45 #[serde(default)]
47 pub redis: RedisConfig,
48
49 #[serde(default)]
51 pub cache: CacheConfig,
52
53 #[serde(default)]
55 pub middleware: MiddlewareConfig,
56
57 #[serde(default)]
59 pub router: RouterConfig,
60
61 #[serde(default)]
63 pub plugins: PluginsConfig,
64
65 #[serde(default)]
67 pub upload: UploadConfig,
68
69 #[serde(default)]
71 pub download: DownloadConfig,
72
73 #[serde(default)]
75 pub template: TemplateConfig,
76
77 #[serde(default)]
79 pub static_files: StaticConfig,
80
81 #[serde(default)]
83 pub custom: HashMap<String, serde_json::Value>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ServerConfig {
89 #[serde(default = "default_listen")]
91 pub listen: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LogConfig {
97 #[serde(default = "default_log_level")]
99 pub level: String,
100
101 #[serde(default = "default_log_format")]
103 pub format: String,
104
105 #[serde(default)]
107 pub dir: Option<String>,
108
109 #[serde(default = "default_log_prefix")]
111 pub file_prefix: String,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct DatabaseConfig {
117 #[serde(default = "default_true")]
119 pub enabled: bool,
120
121 #[serde(default = "default_db_type")]
123 pub r#type: String,
124
125 #[serde(default = "default_host")]
127 pub host: String,
128
129 pub port: Option<u16>,
131
132 #[serde(default)]
134 pub name: String,
135
136 #[serde(default)]
138 pub user: String,
139
140 #[serde(default)]
142 pub password: String,
143
144 #[serde(default)]
146 pub password_encrypted: bool,
147
148 #[serde(default = "default_pool_size")]
150 pub max_connections: u32,
151
152 #[serde(default = "default_min_idle")]
154 pub min_connections: u32,
155
156 #[serde(default = "default_timeout")]
158 pub connect_timeout: u64,
159
160 #[serde(default)]
162 pub sql_logging: bool,
163
164 #[serde(default)]
166 pub slow_query_ms: u64,
167
168 #[serde(default)]
170 pub migration: MigrationConfig,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct RedisConfig {
176 #[serde(default)]
178 pub enabled: bool,
179
180 #[serde(default = "default_redis_url")]
182 pub url: String,
183
184 #[serde(default = "default_pool_size")]
186 pub max_connections: u32,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct CacheConfig {
192 #[serde(default = "default_cache_type")]
194 pub r#type: String,
195
196 #[serde(default = "default_cache_capacity")]
198 pub max_capacity: u64,
199
200 #[serde(default = "default_ttl")]
202 pub default_ttl: u64,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct MiddlewareConfig {
208 #[serde(default)]
210 pub request_id: bool,
211
212 #[serde(default)]
214 pub request_log: bool,
215
216 #[serde(default)]
218 pub request_log_config: RequestLogConfig,
219
220 #[serde(default)]
222 pub auth: AuthMiddlewareConfig,
223
224 #[serde(default)]
226 pub cors: CorsConfig,
227
228 #[serde(default)]
230 pub compression: CompressConfig,
231
232 #[serde(default)]
234 pub rate_limit: RateLimitConfig,
235
236 #[serde(default)]
238 pub security_headers: SecurityHeadersConfig,
239
240 #[serde(default)]
242 pub permission: PermissionConfig,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct RequestLogConfig {
248 #[serde(default)]
250 pub exclude_paths: Vec<String>,
251
252 #[serde(default = "default_true")]
254 pub log_duration: bool,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct AuthMiddlewareConfig {
260 #[serde(default)]
261 pub enabled: bool,
262
263 #[serde(default)]
265 pub ignore_paths: Vec<String>,
266
267 #[serde(default)]
269 pub jwt_secret: String,
270
271 #[serde(default = "default_access_token_expire")]
273 pub access_token_expire_secs: u64,
274
275 #[serde(default = "default_refresh_token_expire")]
277 pub refresh_token_expire_secs: u64,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct CorsConfig {
283 #[serde(default)]
284 pub enabled: bool,
285
286 #[serde(default)]
287 pub allow_origins: Vec<String>,
288
289 #[serde(default)]
290 pub allow_methods: Vec<String>,
291
292 #[serde(default)]
293 pub allow_headers: Vec<String>,
294
295 #[serde(default = "default_true")]
296 pub allow_credentials: bool,
297
298 #[serde(default = "default_cors_max_age")]
299 pub max_age_secs: u64,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct CompressConfig {
305 #[serde(default)]
307 pub enabled: bool,
308
309 #[serde(default = "default_compress_level")]
311 pub level: u32,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct RateLimitConfig {
317 #[serde(default)]
318 pub enabled: bool,
319
320 #[serde(default = "default_rate_limit_requests")]
322 pub requests_per_window: u64,
323
324 #[serde(default = "default_rate_limit_window")]
326 pub window_secs: u64,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
334pub struct SecurityHeadersConfig {
335 #[serde(default = "default_true")]
337 pub enabled: bool,
338
339 #[serde(default = "default_true")]
341 pub nosniff: bool,
342
343 #[serde(default = "default_true")]
345 pub frame_options: bool,
346
347 #[serde(default = "default_true")]
349 pub hsts: bool,
350
351 #[serde(default = "default_hsts_max_age")]
353 pub hsts_max_age_secs: u64,
354
355 #[serde(default = "default_true")]
357 pub hsts_include_subdomains: bool,
358
359 #[serde(default = "default_true")]
361 pub csp: bool,
362
363 #[serde(default = "default_csp_value")]
365 pub csp_value: String,
366
367 #[serde(default = "default_true")]
369 pub referrer_policy: bool,
370
371 #[serde(default = "default_referrer_policy_value")]
373 pub referrer_policy_value: String,
374
375 #[serde(default)]
377 pub permissions_policy: bool,
378
379 #[serde(default = "default_permissions_policy_value")]
381 pub permissions_policy_value: String,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct PermissionConfig {
391 #[serde(default)]
393 pub enabled: bool,
394
395 #[serde(default)]
397 pub rules: Vec<PermissionRule>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct PermissionRule {
403 pub path: String,
405 #[serde(default)]
407 pub methods: Vec<String>,
408 pub permission: String,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct RouterConfig {
415 #[serde(default)]
417 pub prefix: String,
418 #[serde(default)]
420 pub not_found: NotFoundConfig,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct NotFoundConfig {
426 #[serde(default = "default_true")]
428 pub enabled: bool,
429 #[serde(default = "default_not_found_msg")]
431 pub message: String,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct MigrationConfig {
437 #[serde(default)]
439 pub enabled: bool,
440
441 #[serde(default = "default_migration_path")]
443 pub path: String,
444
445 #[serde(default)]
447 pub auto_migrate: bool,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct UploadConfig {
453 #[serde(default = "default_upload_path")]
455 pub path: String,
456
457 #[serde(default = "default_max_size")]
459 pub max_size_mb: u64,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct DownloadConfig {
465 #[serde(default = "default_download_path")]
467 pub path: String,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct TemplateConfig {
473 #[serde(default = "default_template_path")]
475 pub path: String,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct StaticConfig {
481 #[serde(default = "default_static_path")]
483 pub path: String,
484
485 #[serde(default)]
487 pub enabled: bool,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct PluginsConfig {
493 #[serde(default)]
495 pub enabled: Vec<String>,
496
497 #[serde(default)]
499 pub notification: NotificationConfig,
500
501 #[serde(default)]
503 pub async_task: AsyncTaskConfig,
504
505 #[serde(default)]
507 pub scheduler: SchedulerConfig,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize, Default)]
511pub struct NotificationConfig {
512 #[serde(default)]
514 pub enabled: bool,
515 #[serde(default)]
517 pub smtp_host: String,
518 #[serde(default = "default_smtp_port")]
520 pub smtp_port: u16,
521 #[serde(default)]
523 pub smtp_user: String,
524 #[serde(default)]
526 pub smtp_pass: String,
527 #[serde(default)]
529 pub from_email: String,
530 #[serde(default)]
532 pub from_name: String,
533}
534
535fn default_smtp_port() -> u16 { 587 }
536
537#[derive(Debug, Clone, Serialize, Deserialize, Default)]
538pub struct AsyncTaskConfig {
539 #[serde(default = "default_workers")]
541 pub workers: usize,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, Default)]
545pub struct SchedulerConfig {
546 #[serde(default = "default_workers")]
548 pub workers: usize,
549}
550
551fn default_app_name() -> String { "Alun".into() }
554fn default_profile() -> String { "dev".into() }
555fn default_listen() -> String { "8023".into() }
556fn default_log_level() -> String { "info".into() }
557fn default_log_format() -> String { "text".into() }
558fn default_log_prefix() -> String { "alun".into() }
559fn default_db_type() -> String { "postgres".into() }
560fn default_host() -> String { "localhost".into() }
561fn default_true() -> bool { true }
562fn default_pool_size() -> u32 { 10 }
563fn default_min_idle() -> u32 { 2 }
564fn default_timeout() -> u64 { 10 }
565fn default_workers() -> usize { 4 }
566fn default_redis_url() -> String { "redis://127.0.0.1:6379".into() }
567fn default_cache_type() -> String { "local".into() }
568fn default_cache_capacity() -> u64 { 10000 }
569fn default_ttl() -> u64 { 3600 }
570fn default_access_token_expire() -> u64 { 7200 }
571fn default_refresh_token_expire() -> u64 { 604800 }
572fn default_cors_max_age() -> u64 { 86400 }
573fn default_compress_level() -> u32 { 6 }
574fn default_rate_limit_requests() -> u64 { 100 }
575fn default_rate_limit_window() -> u64 { 60 }
576fn default_hsts_max_age() -> u64 { 31536000 }
577fn default_csp_value() -> String { "default-src 'self'".into() }
578fn default_referrer_policy_value() -> String { "strict-origin-when-cross-origin".into() }
579fn default_permissions_policy_value() -> String {
580 "camera=(), microphone=(), geolocation=()".into()
581}
582fn default_migration_path() -> String { "migrations".into() }
583fn default_upload_path() -> String { "uploads".into() }
584fn default_download_path() -> String { "downloads".into() }
585fn default_template_path() -> String { "templates".into() }
586fn default_static_path() -> String { "static".into() }
587fn default_not_found_msg() -> String { "请求的资源不存在".into() }
588fn default_max_size() -> u64 { 10 }
589
590impl Default for AppConfig {
591 fn default() -> Self {
592 Self {
593 app_name: default_app_name(),
594 profile: default_profile(),
595 server: ServerConfig::default(),
596 log: LogConfig::default(),
597 database: DatabaseConfig::default(),
598 redis: RedisConfig::default(),
599 cache: CacheConfig::default(),
600 middleware: MiddlewareConfig::default(),
601 router: RouterConfig::default(),
602 plugins: PluginsConfig::default(),
603 upload: UploadConfig::default(),
604 download: DownloadConfig::default(),
605 template: TemplateConfig::default(),
606 static_files: StaticConfig::default(),
607 custom: HashMap::new(),
608 }
609 }
610}
611
612impl Default for ServerConfig { fn default() -> Self { Self { listen: default_listen() } } }
613impl Default for LogConfig {
614 fn default() -> Self {
615 Self {
616 level: default_log_level(),
617 format: default_log_format(),
618 dir: None,
619 file_prefix: default_log_prefix(),
620 }
621 }
622}
623impl Default for DatabaseConfig {
624 fn default() -> Self {
625 Self {
626 enabled: false, r#type: default_db_type(), host: default_host(),
627 port: None, name: String::new(), user: String::new(), password: String::new(),
628 password_encrypted: false,
629 max_connections: default_pool_size(), min_connections: default_min_idle(),
630 connect_timeout: default_timeout(), sql_logging: false, slow_query_ms: 0,
631 migration: MigrationConfig::default(),
632 }
633 }
634}
635impl Default for RedisConfig { fn default() -> Self { Self { enabled: false, url: default_redis_url(), max_connections: default_pool_size() } } }
636impl Default for CacheConfig { fn default() -> Self { Self { r#type: default_cache_type(), max_capacity: default_cache_capacity(), default_ttl: default_ttl() } } }
637impl Default for MiddlewareConfig {
638 fn default() -> Self {
639 Self {
640 request_id: false, request_log: false,
641 request_log_config: RequestLogConfig::default(),
642 auth: AuthMiddlewareConfig::default(),
643 cors: CorsConfig::default(),
644 compression: CompressConfig::default(),
645 rate_limit: RateLimitConfig::default(),
646 security_headers: SecurityHeadersConfig::default(),
647 permission: PermissionConfig::default(),
648 }
649 }
650}
651impl Default for RequestLogConfig {
652 fn default() -> Self { Self { exclude_paths: vec![], log_duration: true } }
653}
654impl Default for AuthMiddlewareConfig { fn default() -> Self { Self { enabled: false, ignore_paths: vec![], jwt_secret: String::new(), access_token_expire_secs: default_access_token_expire(), refresh_token_expire_secs: default_refresh_token_expire() } } }
655impl Default for CorsConfig { fn default() -> Self { Self { enabled: false, allow_origins: vec![], allow_methods: vec![], allow_headers: vec![], allow_credentials: true, max_age_secs: default_cors_max_age() } } }
656impl Default for CompressConfig { fn default() -> Self { Self { enabled: false, level: default_compress_level() } } }
657impl Default for RateLimitConfig { fn default() -> Self { Self { enabled: false, requests_per_window: default_rate_limit_requests(), window_secs: default_rate_limit_window() } } }
658impl Default for SecurityHeadersConfig {
659 fn default() -> Self {
660 Self {
661 enabled: true,
662 nosniff: true, frame_options: true,
663 hsts: true, hsts_max_age_secs: default_hsts_max_age(),
664 hsts_include_subdomains: true,
665 csp: true, csp_value: default_csp_value(),
666 referrer_policy: true, referrer_policy_value: default_referrer_policy_value(),
667 permissions_policy: false, permissions_policy_value: default_permissions_policy_value(),
668 }
669 }
670}
671impl Default for PermissionConfig { fn default() -> Self { Self { enabled: false, rules: vec![] } } }
672impl Default for PermissionRule { fn default() -> Self { Self { path: String::new(), methods: vec![], permission: String::new() } } }
673impl Default for RouterConfig { fn default() -> Self { Self { prefix: String::new(), not_found: NotFoundConfig::default() } } }
674impl Default for NotFoundConfig { fn default() -> Self { Self { enabled: true, message: default_not_found_msg() } } }
675impl Default for MigrationConfig { fn default() -> Self { Self { enabled: false, path: default_migration_path(), auto_migrate: false } } }
676impl Default for UploadConfig { fn default() -> Self { Self { path: default_upload_path(), max_size_mb: default_max_size() } } }
677impl Default for DownloadConfig { fn default() -> Self { Self { path: default_download_path() } } }
678impl Default for TemplateConfig { fn default() -> Self { Self { path: default_template_path() } } }
679impl Default for StaticConfig { fn default() -> Self { Self { path: default_static_path(), enabled: false } } }
680impl Default for PluginsConfig { fn default() -> Self { Self { enabled: vec![], notification: NotificationConfig::default(), async_task: AsyncTaskConfig::default(), scheduler: SchedulerConfig::default() } } }
681
682pub struct ConfigManager {
686 pub static_config: AppConfig,
688 pub dynamic: RwLock<HashMap<String, serde_json::Value>>,
690}
691
692impl ConfigManager {
693 pub fn load(config_dir: Option<&str>) -> Self {
695 let dir = config_dir.unwrap_or("config");
696 let profile = detect_profile();
697
698 let mut cfg = Self::load_file(dir, &profile);
699
700 merge_env_overrides(&mut cfg);
702
703 info!("配置加载完成 profile={}, listen={}", cfg.profile, cfg.server.listen);
704
705 Self {
706 static_config: cfg,
707 dynamic: RwLock::new(HashMap::new()),
708 }
709 }
710
711 fn load_file(dir: &str, profile: &str) -> AppConfig {
712 let base_path = Path::new(dir).join("config.toml");
714 let mut cfg = if base_path.exists() {
715 let content = fs::read_to_string(&base_path)
716 .unwrap_or_else(|_| String::new());
717 toml::from_str(&content).unwrap_or_default()
718 } else {
719 AppConfig::default()
720 };
721
722 let profile_path = Path::new(dir).join(format!("config-{}.toml", profile));
725 if profile_path.exists() {
726 if let Ok(content) = fs::read_to_string(&profile_path) {
727 if let Ok(profile_cfg) = toml::from_str::<AppConfig>(&content) {
728 merge_configs(&mut cfg, &profile_cfg);
729 }
730 }
731 }
732
733 cfg.profile = profile.to_string();
734 cfg
735 }
736
737 pub fn get(&self) -> &AppConfig {
739 &self.static_config
740 }
741
742 pub fn get_dynamic(&self, key: &str) -> Option<serde_json::Value> {
744 self.dynamic.read().get(key).cloned()
745 }
746
747 pub fn set_dynamic(&self, key: &str, value: serde_json::Value) {
749 self.dynamic.write().insert(key.to_string(), value);
750 }
751
752 pub fn remove_dynamic(&self, key: &str) {
754 self.dynamic.write().remove(key);
755 }
756
757 pub fn generate_default(dir: &str) -> std::io::Result<()> {
759 let config_dir = Path::new(dir);
760 fs::create_dir_all(config_dir)?;
761
762 let cfg = AppConfig::default();
763 let toml_str = toml::to_string_pretty(&cfg)
764 .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
765
766 let header = r#"# Alun 默认配置文件
767# 修改后保存即可生效(需重启服务)
768#
769# 使用 --gen-config 参数可重新生成此文件到 config/config.toml
770# 多环境:创建 config/config-dev.toml, config/config-prod.toml
771# 通过环境变量或命令行 --profile=prod 指定
772
773"#;
774
775 fs::write(config_dir.join("config.toml"), format!("{}{}", header, toml_str))?;
776 info!("默认配置文件已生成到 {}/config.toml", dir);
777 Ok(())
778 }
779}
780
781fn merge_configs(base: &mut AppConfig, overlay: &AppConfig) {
783 if overlay.server.listen != default_listen() { base.server.listen = overlay.server.listen.clone(); }
784 if overlay.log.level != default_log_level() { base.log.level = overlay.log.level.clone(); }
785 if overlay.log.format != default_log_format() { base.log.format = overlay.log.format.clone(); }
786 if overlay.log.dir.is_some() { base.log.dir = overlay.log.dir.clone(); }
787 if overlay.log.file_prefix != default_log_prefix() { base.log.file_prefix = overlay.log.file_prefix.clone(); }
788 if overlay.database.host != default_host() || !overlay.database.name.is_empty() {
789 base.database = overlay.database.clone();
790 }
791 if overlay.redis.url != default_redis_url() { base.redis = overlay.redis.clone(); }
792 if overlay.cache.r#type != default_cache_type() { base.cache = overlay.cache.clone(); }
793 if overlay.router.prefix != String::new() { base.router.prefix = overlay.router.prefix.clone(); }
794 if overlay.router.not_found.message != default_not_found_msg() {
795 base.router.not_found.message = overlay.router.not_found.message.clone();
796 }
797 if !overlay.router.not_found.enabled {
798 base.router.not_found.enabled = false;
799 }
800 if overlay.upload.path != default_upload_path() { base.upload = overlay.upload.clone(); }
801 if overlay.download.path != default_download_path() { base.download = overlay.download.clone(); }
802 if overlay.template.path != default_template_path() { base.template = overlay.template.clone(); }
803 if overlay.static_files.path != default_static_path() { base.static_files = overlay.static_files.clone(); }
804
805 merge_middleware(&mut base.middleware, &overlay.middleware);
807
808 if !overlay.plugins.enabled.is_empty() {
810 base.plugins = overlay.plugins.clone();
811 }
812 for (k, v) in &overlay.custom { base.custom.insert(k.clone(), v.clone()); }
813}
814
815fn merge_middleware(base: &mut MiddlewareConfig, overlay: &MiddlewareConfig) {
816 let default_mw = MiddlewareConfig::default();
817 if overlay.request_id != default_mw.request_id { base.request_id = overlay.request_id; }
818 if overlay.request_log != default_mw.request_log { base.request_log = overlay.request_log; }
819 if overlay.request_log_config.log_duration != default_mw.request_log_config.log_duration {
820 base.request_log_config.log_duration = overlay.request_log_config.log_duration;
821 }
822 if !overlay.request_log_config.exclude_paths.is_empty() {
823 base.request_log_config.exclude_paths = overlay.request_log_config.exclude_paths.clone();
824 }
825 if overlay.auth.enabled != default_mw.auth.enabled { base.auth.enabled = overlay.auth.enabled; }
826 if overlay.auth.jwt_secret != default_mw.auth.jwt_secret { base.auth.jwt_secret = overlay.auth.jwt_secret.clone(); }
827 if overlay.auth.access_token_expire_secs != 0 { base.auth.access_token_expire_secs = overlay.auth.access_token_expire_secs; }
828 if overlay.auth.refresh_token_expire_secs != 0 { base.auth.refresh_token_expire_secs = overlay.auth.refresh_token_expire_secs; }
829 if !overlay.auth.ignore_paths.is_empty() { base.auth.ignore_paths = overlay.auth.ignore_paths.clone(); }
830 if overlay.cors.enabled != default_mw.cors.enabled { base.cors.enabled = overlay.cors.enabled; }
831 if !overlay.cors.allow_origins.is_empty() { base.cors.allow_origins = overlay.cors.allow_origins.clone(); }
832 if overlay.compression.enabled != default_mw.compression.enabled { base.compression.enabled = overlay.compression.enabled; }
833 if overlay.rate_limit.enabled != default_mw.rate_limit.enabled { base.rate_limit.enabled = overlay.rate_limit.enabled; }
834 if overlay.rate_limit.requests_per_window != 0 { base.rate_limit.requests_per_window = overlay.rate_limit.requests_per_window; }
835 if overlay.rate_limit.window_secs != 0 { base.rate_limit.window_secs = overlay.rate_limit.window_secs; }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[test]
843 fn test_default_config_serialization() {
844 let cfg = AppConfig::default();
845 let toml_str = toml::to_string_pretty(&cfg).unwrap();
846 assert!(toml_str.contains("listen = \"8023\""));
847 assert!(toml_str.contains("level = \"info\""));
848 }
849}