1use serde::de::DeserializeOwned;
18use serde::Deserialize;
19use std::collections::HashMap;
20use std::path::Path;
21use std::sync::Arc;
22use thiserror::Error;
23
24#[derive(Debug, Error)]
26pub enum ConfigError {
27 #[error("配置文件读取失败: {path} — {source}")]
29 FileRead {
30 path: String,
32 #[source]
34 source: std::io::Error,
35 },
36 #[error("配置文件解析失败: {path} — {source}")]
38 Parse {
39 path: String,
41 #[source]
43 source: serde_yaml::Error,
44 },
45}
46
47#[derive(Debug, Clone, Deserialize, Default)]
49pub struct AppConfig {
50 #[serde(default)]
52 pub app: AppSection,
53 #[serde(default)]
55 pub database: DatabaseSection,
56 #[serde(default)]
58 pub cache: CacheSection,
59 #[serde(default)]
61 pub addons: AddonsSection,
62 #[serde(default)]
64 pub log: LogSection,
65 #[serde(default)]
67 pub server: ServerSection,
68}
69
70#[derive(Debug, Clone, Deserialize)]
72pub struct AppSection {
73 #[serde(default)]
75 pub app_host: String,
76 #[serde(default)]
78 pub app_namespace: String,
79 #[serde(default = "default_true")]
81 pub with_route: bool,
82 #[serde(default = "default_true")]
84 pub with_event: bool,
85 #[serde(default = "default_default_app")]
87 pub default_app: String,
88 #[serde(default = "default_timezone")]
90 pub default_timezone: String,
91 #[serde(default = "default_true")]
93 pub auto_multi_app: bool,
94 #[serde(default = "default_app_map")]
96 pub app_map: HashMap<String, String>,
97 #[serde(default = "default_deny_app_list")]
99 pub deny_app_list: Vec<String>,
100}
101
102impl Default for AppSection {
103 fn default() -> Self {
104 Self {
105 app_host: String::new(),
106 app_namespace: String::new(),
107 with_route: true,
108 with_event: true,
109 default_app: default_default_app(),
110 default_timezone: default_timezone(),
111 auto_multi_app: true,
112 app_map: default_app_map(),
113 deny_app_list: default_deny_app_list(),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Deserialize)]
120pub struct DatabaseSection {
121 #[serde(default = "default_mysql")]
123 pub default: String,
124 #[serde(default = "default_true")]
126 pub auto_timestamp: bool,
127 #[serde(default = "default_datetime_format")]
129 pub datetime_format: String,
130 #[serde(default)]
132 pub connections: HashMap<String, DatabaseConnection>,
133}
134
135impl Default for DatabaseSection {
136 fn default() -> Self {
137 Self {
138 default: default_mysql(),
139 auto_timestamp: true,
140 datetime_format: default_datetime_format(),
141 connections: HashMap::new(),
142 }
143 }
144}
145
146#[derive(Debug, Clone, Deserialize)]
148pub struct DatabaseConnection {
149 #[serde(default = "default_mysql")]
151 pub r#type: String,
152 #[serde(default)]
154 pub hostname: String,
155 #[serde(default)]
157 pub database: String,
158 #[serde(default)]
160 pub username: String,
161 #[serde(default, skip_serializing)]
166 pub password: String,
167 #[serde(default = "default_port_8802")]
169 pub hostport: u16,
170 #[serde(default = "default_charset_utf8mb4")]
172 pub charset: String,
173 #[serde(default)]
175 pub prefix: String,
176 #[serde(default)]
178 pub deploy: u8,
179 #[serde(default)]
181 pub rw_separate: bool,
182 #[serde(default = "default_true")]
184 pub fields_strict: bool,
185 #[serde(default = "default_true")]
187 pub break_reconnect: bool,
188}
189
190#[derive(Debug, Clone, Deserialize, Default)]
192pub struct CacheSection {
193 #[serde(default = "default_cache_memory")]
195 pub default: String,
196 #[serde(default)]
198 pub stores: HashMap<String, CacheStore>,
199}
200
201#[derive(Debug, Clone, Deserialize, Default)]
203pub struct CacheStore {
204 #[serde(default)]
206 pub r#type: String,
207 #[serde(default)]
209 pub capacity: usize,
210 #[serde(default)]
212 pub levels: Vec<String>,
213}
214
215#[derive(Debug, Clone, Deserialize, Default)]
217pub struct AddonsSection {
218 #[serde(default = "default_addons_path")]
220 pub addons_path: String,
221 #[serde(default)]
223 pub priority: AddonsPriority,
224}
225
226#[derive(Debug, Clone, Deserialize, Default)]
228pub struct AddonsPriority {
229 #[serde(default)]
231 pub p0: Vec<String>,
232 #[serde(default)]
234 pub p1: Vec<String>,
235 #[serde(default)]
237 pub p2: Vec<String>,
238}
239
240#[derive(Debug, Clone, Deserialize, Default)]
242pub struct LogSection {
243 #[serde(default = "default_log_file")]
245 pub default: String,
246 #[serde(default)]
248 pub channels: HashMap<String, LogChannel>,
249}
250
251#[derive(Debug, Clone, Deserialize, Default)]
253pub struct LogChannel {
254 #[serde(default)]
256 pub r#type: String,
257 #[serde(default)]
259 pub path: String,
260 #[serde(default = "default_log_level")]
262 pub level: String,
263 #[serde(default)]
265 pub max_files: u32,
266 #[serde(default)]
268 pub format: String,
269}
270
271fn default_true() -> bool {
276 true
277}
278
279fn default_default_app() -> String {
280 "index".to_string()
281}
282
283fn default_timezone() -> String {
284 "Asia/Shanghai".to_string()
285}
286
287fn default_app_map() -> HashMap<String, String> {
288 let mut map = HashMap::new();
289 map.insert("oapc".to_string(), "oapc".to_string());
290 map.insert("admin".to_string(), "admin".to_string());
291 map.insert("api".to_string(), "api".to_string());
292 map.insert("farm".to_string(), "farm".to_string());
293 map.insert("oapi".to_string(), "oapi".to_string());
294 map.insert("cashier".to_string(), "cashier".to_string());
295 map.insert("scene".to_string(), "scene".to_string());
296 map
297}
298
299fn default_deny_app_list() -> Vec<String> {
300 vec!["common".to_string()]
301}
302
303#[derive(Debug, Clone, Deserialize)]
308pub struct ServerSection {
309 #[serde(default = "default_server_host")]
311 pub host: String,
312 #[serde(default = "default_server_port")]
314 pub port: u16,
315}
316
317impl Default for ServerSection {
318 fn default() -> Self {
319 Self {
320 host: default_server_host(),
321 port: default_server_port(),
322 }
323 }
324}
325
326fn default_server_host() -> String {
327 "0.0.0.0".to_string()
328}
329
330fn default_server_port() -> u16 {
331 8080
332}
333
334fn default_mysql() -> String {
335 "mysql".to_string()
336}
337
338fn default_datetime_format() -> String {
339 "Y-m-d H:i:s".to_string()
340}
341
342fn default_port_8802() -> u16 {
343 8802
344}
345
346fn default_charset_utf8mb4() -> String {
347 "utf8mb4".to_string()
348}
349
350fn default_cache_memory() -> String {
351 "memory".to_string()
352}
353
354fn default_addons_path() -> String {
355 "addons".to_string()
356}
357
358fn default_log_file() -> String {
359 "file".to_string()
360}
361
362fn default_log_level() -> String {
363 "info".to_string()
364}
365
366impl AppConfig {
371 #[tracing::instrument(skip_all)]
384 pub async fn load_from_dir(config_dir: impl AsRef<Path>) -> Result<Self, ConfigError> {
385 let dir = config_dir.as_ref();
386
387 let mut config = AppConfig {
389 app: load_section(&dir.join("app.yml"), AppSection::default()).await?,
390 database: load_section(&dir.join("database.yml"), DatabaseSection::default()).await?,
391 cache: load_section(&dir.join("cache.yml"), CacheSection::default()).await?,
392 addons: load_section(&dir.join("addons.yml"), AddonsSection::default()).await?,
393 log: load_section(&dir.join("log.yml"), LogSection::default()).await?,
394 server: load_section(&dir.join("server.yml"), ServerSection::default()).await?,
395 };
396
397 config.apply_env_overrides();
399
400 Ok(config)
401 }
402
403 #[tracing::instrument(skip(self))]
411 pub fn apply_env_overrides(&mut self) {
412 for (conn_name, conn) in &mut self.database.connections {
414 let prefix = format!("SZ_DB_{}", conn_name.to_uppercase());
415
416 let env_key = format!("{}_PASSWORD", prefix);
418 if let Ok(password) = std::env::var(&env_key) {
419 if !password.is_empty() {
420 conn.password = password;
421 }
422 }
423
424 let env_key = format!("{}_HOSTNAME", prefix);
426 if let Ok(hostname) = std::env::var(&env_key) {
427 if !hostname.is_empty() {
428 conn.hostname = hostname;
429 }
430 }
431
432 let env_key = format!("{}_HOSTPORT", prefix);
434 if let Ok(hostport_str) = std::env::var(&env_key) {
435 if !hostport_str.is_empty() {
436 if let Ok(hostport) = hostport_str.parse() {
437 conn.hostport = hostport;
438 }
439 }
440 }
441 }
442 }
443
444 pub fn default_connection(&self) -> Option<&DatabaseConnection> {
446 self.database.connections.get(&self.database.default)
447 }
448}
449
450async fn load_section<T: DeserializeOwned + Default>(
452 path: &Path,
453 default: T,
454) -> Result<T, ConfigError> {
455 if !path.exists() {
456 return Ok(default);
457 }
458 let content = tokio::fs::read_to_string(path)
459 .await
460 .map_err(|e| ConfigError::FileRead {
461 path: path.display().to_string(),
462 source: e,
463 })?;
464 serde_yaml::from_str(&content).map_err(|e| ConfigError::Parse {
465 path: path.display().to_string(),
466 source: e,
467 })
468}
469
470pub struct ConfigWatcher {
507 config_dir: std::path::PathBuf,
509 shared_config: Arc<parking_lot::RwLock<AppConfig>>,
511 poll_interval_secs: u64,
513 last_mtimes: parking_lot::RwLock<HashMap<String, std::time::SystemTime>>,
515}
516
517pub struct ConfigWatcherHandle {
519 cancel: tokio_util::sync::CancellationToken,
520}
521
522impl ConfigWatcherHandle {
523 pub fn stop(&self) {
525 self.cancel.cancel();
526 }
527}
528
529impl ConfigWatcher {
530 pub fn new(
537 config_dir: impl Into<std::path::PathBuf>,
538 shared_config: Arc<parking_lot::RwLock<AppConfig>>,
539 ) -> Self {
540 Self {
541 config_dir: config_dir.into(),
542 shared_config,
543 poll_interval_secs: 5,
544 last_mtimes: parking_lot::RwLock::new(HashMap::new()),
545 }
546 }
547
548 #[must_use]
550 pub fn with_poll_interval(mut self, secs: u64) -> Self {
551 self.poll_interval_secs = secs;
552 self
553 }
554
555 async fn init_mtimes(&self) {
557 let files = self.config_files();
558 let mut updates = Vec::new();
560 for file in &files {
561 if let Ok(meta) = tokio::fs::metadata(file).await {
562 if let Ok(mtime) = meta.modified() {
563 updates.push((file.display().to_string(), mtime));
564 }
565 }
566 }
567 let mut mtimes = self.last_mtimes.write();
569 for (key, mtime) in updates {
570 mtimes.insert(key, mtime);
571 }
572 }
573
574 fn config_files(&self) -> Vec<std::path::PathBuf> {
576 let names = [
577 "app.yml",
578 "database.yml",
579 "cache.yml",
580 "addons.yml",
581 "log.yml",
582 "server.yml",
583 ];
584 names.iter().map(|n| self.config_dir.join(n)).collect()
585 }
586
587 async fn has_changes(&self) -> bool {
591 let files = self.config_files();
592 let mtimes_snapshot: std::collections::HashMap<String, std::time::SystemTime> = {
594 let mtimes = self.last_mtimes.read();
595 mtimes.clone()
596 };
597 for file in &files {
598 if let Ok(meta) = tokio::fs::metadata(file).await {
599 if let Ok(mtime) = meta.modified() {
600 let key = file.display().to_string();
601 if let Some(last) = mtimes_snapshot.get(&key) {
602 if last != &mtime {
603 return true;
604 }
605 } else {
606 return true;
608 }
609 }
610 }
611 }
612 false
613 }
614
615 async fn update_mtimes(&self) {
617 let files = self.config_files();
618 let mut updates = Vec::new();
619 for file in &files {
620 if let Ok(meta) = tokio::fs::metadata(file).await {
621 if let Ok(mtime) = meta.modified() {
622 updates.push((file.display().to_string(), mtime));
623 }
624 }
625 }
626 let mut mtimes = self.last_mtimes.write();
627 for (key, mtime) in updates {
628 mtimes.insert(key, mtime);
629 }
630 }
631
632 pub fn start(self) -> ConfigWatcherHandle {
636 let cancel = tokio_util::sync::CancellationToken::new();
637 let cancel_clone = cancel.clone();
638
639 let config_dir = self.config_dir.clone();
640 let shared_config = self.shared_config.clone();
641 let poll_interval = std::time::Duration::from_secs(self.poll_interval_secs);
642 let watcher = self;
643
644 tokio::spawn(async move {
645 watcher.init_mtimes().await;
647
648 let mut ticker = tokio::time::interval(poll_interval);
649 ticker.tick().await; loop {
652 tokio::select! {
653 _ = cancel_clone.cancelled() => {
654 tracing::info!("配置热重载监听已停止");
655 break;
656 }
657 _ = ticker.tick() => {
658 if watcher.has_changes().await {
659 tracing::info!("检测到配置文件变化,正在重新加载...");
660 match AppConfig::load_from_dir(&config_dir).await {
661 Ok(new_config) => {
662 *shared_config.write() = new_config;
663 watcher.update_mtimes().await;
664 tracing::info!("配置热重载完成");
665 }
666 Err(e) => {
667 tracing::error!("配置热重载失败,保留旧配置: {e}");
668 watcher.update_mtimes().await;
669 }
670 }
671 }
672 }
673 }
674 }
675 });
676
677 ConfigWatcherHandle { cancel }
678 }
679}
680
681#[cfg(test)]
686mod tests {
687 use super::*;
688
689 static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
692
693 #[test]
695 fn test_default_config() {
696 let config = AppConfig::default();
697 assert!(config.app.auto_multi_app);
698 assert!(config.app.with_route);
699 assert_eq!(config.app.default_app, "index");
700 assert_eq!(config.app.default_timezone, "Asia/Shanghai");
701 assert_eq!(config.app.app_map.len(), 7);
702 assert!(config.app.app_map.contains_key("oapc"));
703 assert_eq!(config.app.deny_app_list, vec!["common"]);
704
705 assert_eq!(config.database.default, "mysql");
706 assert!(config.database.auto_timestamp);
707 assert_eq!(config.database.datetime_format, "Y-m-d H:i:s");
708
709 assert_eq!(config.server.host, "0.0.0.0");
711 assert_eq!(config.server.port, 8080);
712 }
713
714 #[test]
716 fn test_load_from_yaml_string() {
717 let yaml = r#"
718app_host: "https://example.com"
719default_app: "api"
720auto_multi_app: true
721app_map:
722 oapc: oapc
723 admin: admin
724"#;
725 let app: AppSection = serde_yaml::from_str(yaml).unwrap();
726 assert_eq!(app.app_host, "https://example.com");
727 assert_eq!(app.default_app, "api");
728 assert!(app.auto_multi_app);
729 assert_eq!(app.app_map.len(), 2);
730 }
731
732 #[tokio::test]
734 async fn test_load_from_dir() {
735 let config_dir = std::env::current_dir().ok().and_then(|d| {
737 let mut current = d.clone();
740 for _ in 0..5 {
741 if current.join("config").exists() {
742 return Some(current.join("config"));
743 }
744 if let Some(parent) = current.parent() {
745 current = parent.to_path_buf();
746 } else {
747 break;
748 }
749 }
750 None
751 });
752
753 if let Some(config_dir) = config_dir {
754 let config = AppConfig::load_from_dir(&config_dir).await.unwrap();
755 assert_eq!(config.app.default_app, "index");
757 assert!(config.app.auto_multi_app);
758 assert_eq!(config.app.app_map.len(), 7);
759 assert_eq!(config.app.deny_app_list, vec!["common"]);
760
761 assert_eq!(config.database.default, "mysql");
763 assert_eq!(config.database.connections.len(), 5);
764 assert!(config.database.connections.contains_key("mysql"));
765 assert!(config.database.connections.contains_key("njszjt"));
766 assert!(config.database.connections.contains_key("ljclz"));
767 assert!(config.database.connections.contains_key("food"));
768 assert!(config.database.connections.contains_key("oceanbase"));
769
770 let mysql = config.database.connections.get("mysql").unwrap();
772 assert_eq!(mysql.hostname, "localhost");
773 assert_eq!(mysql.hostport, 8802);
774 assert_eq!(mysql.charset, "utf8mb4");
775 assert_eq!(mysql.prefix, "sz_");
776
777 let ljclz = config.database.connections.get("ljclz").unwrap();
779 assert_eq!(ljclz.charset, "utf8");
780 assert_eq!(ljclz.prefix, "ims_");
781
782 let oceanbase = config.database.connections.get("oceanbase").unwrap();
784 assert_eq!(oceanbase.hostport, 2881);
785 assert_eq!(oceanbase.hostname, "localhost");
786
787 assert_eq!(config.cache.default, "memory");
789 assert!(config.cache.stores.contains_key("memory"));
790
791 assert_eq!(config.addons.addons_path, "addons");
793 assert_eq!(config.addons.priority.p0.len(), 3);
794
795 assert_eq!(config.log.default, "file");
797 assert!(config.log.channels.contains_key("file"));
798
799 assert_eq!(config.server.host, "0.0.0.0");
801 assert_eq!(config.server.port, 8080);
802 }
803 }
804
805 #[tokio::test]
807 async fn test_load_missing_file_uses_default() {
808 let temp_dir = std::env::temp_dir().join("sz_rust_config_test_missing");
809 let _ = std::fs::create_dir_all(&temp_dir);
810 let config = AppConfig::load_from_dir(&temp_dir).await.unwrap();
812 assert!(config.app.auto_multi_app);
813 assert_eq!(config.database.default, "mysql");
814 let _ = std::fs::remove_dir_all(&temp_dir);
815 }
816
817 #[test]
819 fn test_env_override_password() {
820 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
822 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
824
825 let mut config = AppConfig::default();
826 config.database.connections.insert(
827 "mysql".to_string(),
828 DatabaseConnection {
829 r#type: "mysql".to_string(),
830 hostname: "localhost".to_string(),
831 database: "test".to_string(),
832 username: "root".to_string(),
833 password: String::new(),
834 hostport: 3306,
835 charset: "utf8mb4".to_string(),
836 prefix: "sz_".to_string(),
837 deploy: 0,
838 rw_separate: false,
839 fields_strict: true,
840 break_reconnect: true,
841 },
842 );
843
844 std::env::set_var("SZ_DB_MYSQL_PASSWORD", "secret123");
846
847 config.apply_env_overrides();
849
850 assert_eq!(config.database.connections["mysql"].password, "secret123");
852
853 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
855 }
856
857 #[test]
859 fn test_env_override_empty_ignored() {
860 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
862 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
864
865 let mut config = AppConfig::default();
866 config.database.connections.insert(
867 "mysql".to_string(),
868 DatabaseConnection {
869 r#type: "mysql".to_string(),
870 hostname: "localhost".to_string(),
871 database: "test".to_string(),
872 username: "root".to_string(),
873 password: "existing".to_string(),
874 hostport: 3306,
875 charset: "utf8mb4".to_string(),
876 prefix: "sz_".to_string(),
877 deploy: 0,
878 rw_separate: false,
879 fields_strict: true,
880 break_reconnect: true,
881 },
882 );
883
884 std::env::set_var("SZ_DB_MYSQL_PASSWORD", "");
886
887 config.apply_env_overrides();
888
889 assert_eq!(config.database.connections["mysql"].password, "existing");
891
892 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
893 }
894
895 #[test]
897 fn test_default_connection() {
898 let mut config = AppConfig::default();
899 config.database.default = "mysql".to_string();
900 config.database.connections.insert(
901 "mysql".to_string(),
902 DatabaseConnection {
903 r#type: "mysql".to_string(),
904 hostname: "localhost".to_string(),
905 database: "test".to_string(),
906 username: "root".to_string(),
907 password: String::new(),
908 hostport: 3306,
909 charset: "utf8mb4".to_string(),
910 prefix: "sz_".to_string(),
911 deploy: 0,
912 rw_separate: false,
913 fields_strict: true,
914 break_reconnect: true,
915 },
916 );
917
918 let conn = config.default_connection();
919 assert!(conn.is_some());
920 assert_eq!(conn.unwrap().hostname, "localhost");
921 }
922
923 #[test]
925 fn test_default_connection_missing() {
926 let config = AppConfig::default();
927 assert!(config.default_connection().is_none());
928 }
929
930 #[test]
935 fn test_env_override_hostname() {
936 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
937 std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
938
939 let mut config = AppConfig::default();
940 config.database.connections.insert(
941 "mysql".to_string(),
942 DatabaseConnection {
943 r#type: "mysql".to_string(),
944 hostname: "localhost".to_string(),
945 database: "test".to_string(),
946 username: "root".to_string(),
947 password: String::new(),
948 hostport: 3306,
949 charset: "utf8mb4".to_string(),
950 prefix: "sz_".to_string(),
951 deploy: 0,
952 rw_separate: false,
953 fields_strict: true,
954 break_reconnect: true,
955 },
956 );
957
958 std::env::set_var("SZ_DB_MYSQL_HOSTNAME", "10.0.0.5");
959 config.apply_env_overrides();
960
961 assert_eq!(config.database.connections["mysql"].hostname, "10.0.0.5");
962
963 std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
964 }
965
966 #[test]
968 fn test_env_override_hostport() {
969 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
970 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
971
972 let mut config = AppConfig::default();
973 config.database.connections.insert(
974 "mysql".to_string(),
975 DatabaseConnection {
976 r#type: "mysql".to_string(),
977 hostname: "localhost".to_string(),
978 database: "test".to_string(),
979 username: "root".to_string(),
980 password: String::new(),
981 hostport: 3306,
982 charset: "utf8mb4".to_string(),
983 prefix: "sz_".to_string(),
984 deploy: 0,
985 rw_separate: false,
986 fields_strict: true,
987 break_reconnect: true,
988 },
989 );
990
991 std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "8802");
992 config.apply_env_overrides();
993
994 assert_eq!(config.database.connections["mysql"].hostport, 8802);
995
996 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
997 }
998
999 #[test]
1001 fn test_env_override_hostport_invalid_ignored() {
1002 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
1003 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
1004
1005 let mut config = AppConfig::default();
1006 config.database.connections.insert(
1007 "mysql".to_string(),
1008 DatabaseConnection {
1009 r#type: "mysql".to_string(),
1010 hostname: "localhost".to_string(),
1011 database: "test".to_string(),
1012 username: "root".to_string(),
1013 password: String::new(),
1014 hostport: 3306,
1015 charset: "utf8mb4".to_string(),
1016 prefix: "sz_".to_string(),
1017 deploy: 0,
1018 rw_separate: false,
1019 fields_strict: true,
1020 break_reconnect: true,
1021 },
1022 );
1023
1024 std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "not-a-number");
1025 config.apply_env_overrides();
1026
1027 assert_eq!(config.database.connections["mysql"].hostport, 3306);
1029
1030 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
1031 }
1032
1033 #[test]
1035 fn test_parse_error() {
1036 let bad_yaml = "default: mysql\n bad: : : indent";
1037 let result: Result<DatabaseSection, _> = serde_yaml::from_str(bad_yaml);
1038 let _ = result;
1041 }
1042
1043 #[tokio::test]
1048 async fn test_config_watcher_has_changes_false_on_init() {
1049 let dir = std::env::temp_dir().join("sz_rust_watcher_test_init");
1050 let _ = std::fs::create_dir_all(&dir);
1051 std::fs::write(dir.join("app.yml"), "default_app: test\n").unwrap();
1052
1053 let config = AppConfig::load_from_dir(&dir).await.unwrap();
1054 let shared = Arc::new(parking_lot::RwLock::new(config));
1055 let watcher = ConfigWatcher::new(&dir, shared);
1056
1057 watcher.init_mtimes().await;
1059
1060 assert!(!watcher.has_changes().await);
1062
1063 let _ = std::fs::remove_dir_all(&dir);
1064 }
1065
1066 #[tokio::test]
1067 async fn test_config_watcher_detects_file_modification() {
1068 let dir = std::env::temp_dir().join("sz_rust_watcher_test_modify");
1069 let _ = std::fs::create_dir_all(&dir);
1070 std::fs::write(dir.join("app.yml"), "default_app: before\n").unwrap();
1071
1072 let config = AppConfig::load_from_dir(&dir).await.unwrap();
1073 let shared = Arc::new(parking_lot::RwLock::new(config));
1074 let watcher = ConfigWatcher::new(&dir, shared);
1075
1076 watcher.init_mtimes().await;
1077 assert!(!watcher.has_changes().await);
1078
1079 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1081 std::fs::write(dir.join("app.yml"), "default_app: after\n").unwrap();
1082
1083 assert!(watcher.has_changes().await);
1085
1086 watcher.update_mtimes().await;
1088 assert!(!watcher.has_changes().await);
1089
1090 let _ = std::fs::remove_dir_all(&dir);
1091 }
1092
1093 #[tokio::test]
1094 async fn test_config_watcher_detects_new_file() {
1095 let dir = std::env::temp_dir().join("sz_rust_watcher_test_new");
1096 let _ = std::fs::create_dir_all(&dir);
1097
1098 let config = AppConfig::load_from_dir(&dir).await.unwrap();
1099 let shared = Arc::new(parking_lot::RwLock::new(config));
1100 let watcher = ConfigWatcher::new(&dir, shared);
1101
1102 watcher.init_mtimes().await;
1103
1104 std::fs::write(dir.join("app.yml"), "default_app: new\n").unwrap();
1106
1107 assert!(watcher.has_changes().await);
1109
1110 let _ = std::fs::remove_dir_all(&dir);
1111 }
1112
1113 #[tokio::test]
1114 async fn test_config_watcher_hot_reload() {
1115 let dir = std::env::temp_dir().join("sz_rust_watcher_test_hot");
1116 let _ = std::fs::create_dir_all(&dir);
1117 std::fs::write(dir.join("app.yml"), "default_app: before\n").unwrap();
1118
1119 let config = AppConfig::load_from_dir(&dir).await.unwrap();
1120 let shared = Arc::new(parking_lot::RwLock::new(config));
1121 let watcher = ConfigWatcher::new(&dir, shared.clone()).with_poll_interval(1); let handle = watcher.start();
1124
1125 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1127
1128 std::thread::sleep(std::time::Duration::from_millis(100));
1130 std::fs::write(dir.join("app.yml"), "default_app: hot_reloaded\n").unwrap();
1131
1132 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
1134
1135 let current = shared.read().clone();
1137 assert_eq!(current.app.default_app, "hot_reloaded");
1138
1139 handle.stop();
1140 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1141
1142 let _ = std::fs::remove_dir_all(&dir);
1143 }
1144}