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_yml::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 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())?,
390 database: load_section(&dir.join("database.yml"), DatabaseSection::default())?,
391 cache: load_section(&dir.join("cache.yml"), CacheSection::default())?,
392 addons: load_section(&dir.join("addons.yml"), AddonsSection::default())?,
393 log: load_section(&dir.join("log.yml"), LogSection::default())?,
394 server: load_section(&dir.join("server.yml"), ServerSection::default())?,
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
450fn load_section<T: DeserializeOwned + Default>(path: &Path, default: T) -> Result<T, ConfigError> {
452 if !path.exists() {
453 return Ok(default);
454 }
455 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::FileRead {
456 path: path.display().to_string(),
457 source: e,
458 })?;
459 serde_yml::from_str(&content).map_err(|e| ConfigError::Parse {
460 path: path.display().to_string(),
461 source: e,
462 })
463}
464
465pub struct ConfigWatcher {
502 config_dir: std::path::PathBuf,
504 shared_config: Arc<parking_lot::RwLock<AppConfig>>,
506 poll_interval_secs: u64,
508 last_mtimes: parking_lot::RwLock<HashMap<String, std::time::SystemTime>>,
510}
511
512pub struct ConfigWatcherHandle {
514 cancel: tokio_util::sync::CancellationToken,
515}
516
517impl ConfigWatcherHandle {
518 pub fn stop(&self) {
520 self.cancel.cancel();
521 }
522}
523
524impl ConfigWatcher {
525 pub fn new(
532 config_dir: impl Into<std::path::PathBuf>,
533 shared_config: Arc<parking_lot::RwLock<AppConfig>>,
534 ) -> Self {
535 Self {
536 config_dir: config_dir.into(),
537 shared_config,
538 poll_interval_secs: 5,
539 last_mtimes: parking_lot::RwLock::new(HashMap::new()),
540 }
541 }
542
543 #[must_use]
545 pub fn with_poll_interval(mut self, secs: u64) -> Self {
546 self.poll_interval_secs = secs;
547 self
548 }
549
550 fn init_mtimes(&self) {
552 let files = self.config_files();
553 let mut mtimes = self.last_mtimes.write();
554 for file in &files {
555 if let Ok(meta) = std::fs::metadata(file) {
556 if let Ok(mtime) = meta.modified() {
557 mtimes.insert(file.display().to_string(), mtime);
558 }
559 }
560 }
561 }
562
563 fn config_files(&self) -> Vec<std::path::PathBuf> {
565 let names = [
566 "app.yml",
567 "database.yml",
568 "cache.yml",
569 "addons.yml",
570 "log.yml",
571 "server.yml",
572 ];
573 names.iter().map(|n| self.config_dir.join(n)).collect()
574 }
575
576 fn has_changes(&self) -> bool {
580 let files = self.config_files();
581 let mtimes = self.last_mtimes.read();
582 for file in &files {
583 if let Ok(meta) = std::fs::metadata(file) {
584 if let Ok(mtime) = meta.modified() {
585 let key = file.display().to_string();
586 if let Some(last) = mtimes.get(&key) {
587 if last != &mtime {
588 return true;
589 }
590 } else {
591 return true;
593 }
594 }
595 }
596 }
597 false
598 }
599
600 fn update_mtimes(&self) {
602 let files = self.config_files();
603 let mut mtimes = self.last_mtimes.write();
604 for file in &files {
605 if let Ok(meta) = std::fs::metadata(file) {
606 if let Ok(mtime) = meta.modified() {
607 mtimes.insert(file.display().to_string(), mtime);
608 }
609 }
610 }
611 }
612
613 pub fn start(self) -> ConfigWatcherHandle {
617 let cancel = tokio_util::sync::CancellationToken::new();
618 let cancel_clone = cancel.clone();
619
620 self.init_mtimes();
622
623 let config_dir = self.config_dir.clone();
624 let shared_config = self.shared_config.clone();
625 let poll_interval = std::time::Duration::from_secs(self.poll_interval_secs);
626 let watcher = self;
627
628 tokio::spawn(async move {
629 let mut ticker = tokio::time::interval(poll_interval);
630 ticker.tick().await; loop {
633 tokio::select! {
634 _ = cancel_clone.cancelled() => {
635 tracing::info!("配置热重载监听已停止");
636 break;
637 }
638 _ = ticker.tick() => {
639 if watcher.has_changes() {
640 tracing::info!("检测到配置文件变化,正在重新加载...");
641 match AppConfig::load_from_dir(&config_dir) {
642 Ok(new_config) => {
643 *shared_config.write() = new_config;
644 watcher.update_mtimes();
645 tracing::info!("配置热重载完成");
646 }
647 Err(e) => {
648 tracing::error!("配置热重载失败,保留旧配置: {e}");
649 watcher.update_mtimes();
650 }
651 }
652 }
653 }
654 }
655 }
656 });
657
658 ConfigWatcherHandle { cancel }
659 }
660}
661
662#[cfg(test)]
667mod tests {
668 use super::*;
669
670 static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
673
674 #[test]
676 fn test_default_config() {
677 let config = AppConfig::default();
678 assert!(config.app.auto_multi_app);
679 assert!(config.app.with_route);
680 assert_eq!(config.app.default_app, "index");
681 assert_eq!(config.app.default_timezone, "Asia/Shanghai");
682 assert_eq!(config.app.app_map.len(), 7);
683 assert!(config.app.app_map.contains_key("oapc"));
684 assert_eq!(config.app.deny_app_list, vec!["common"]);
685
686 assert_eq!(config.database.default, "mysql");
687 assert!(config.database.auto_timestamp);
688 assert_eq!(config.database.datetime_format, "Y-m-d H:i:s");
689
690 assert_eq!(config.server.host, "0.0.0.0");
692 assert_eq!(config.server.port, 8080);
693 }
694
695 #[test]
697 fn test_load_from_yaml_string() {
698 let yaml = r#"
699app_host: "https://example.com"
700default_app: "api"
701auto_multi_app: true
702app_map:
703 oapc: oapc
704 admin: admin
705"#;
706 let app: AppSection = serde_yml::from_str(yaml).unwrap();
707 assert_eq!(app.app_host, "https://example.com");
708 assert_eq!(app.default_app, "api");
709 assert!(app.auto_multi_app);
710 assert_eq!(app.app_map.len(), 2);
711 }
712
713 #[test]
715 fn test_load_from_dir() {
716 let config_dir = std::env::current_dir().ok().and_then(|d| {
718 let mut current = d.clone();
721 for _ in 0..5 {
722 if current.join("config").exists() {
723 return Some(current.join("config"));
724 }
725 if let Some(parent) = current.parent() {
726 current = parent.to_path_buf();
727 } else {
728 break;
729 }
730 }
731 None
732 });
733
734 if let Some(config_dir) = config_dir {
735 let config = AppConfig::load_from_dir(&config_dir).unwrap();
736 assert_eq!(config.app.default_app, "index");
738 assert!(config.app.auto_multi_app);
739 assert_eq!(config.app.app_map.len(), 7);
740 assert_eq!(config.app.deny_app_list, vec!["common"]);
741
742 assert_eq!(config.database.default, "mysql");
744 assert_eq!(config.database.connections.len(), 5);
745 assert!(config.database.connections.contains_key("mysql"));
746 assert!(config.database.connections.contains_key("njszjt"));
747 assert!(config.database.connections.contains_key("ljclz"));
748 assert!(config.database.connections.contains_key("food"));
749 assert!(config.database.connections.contains_key("oceanbase"));
750
751 let mysql = config.database.connections.get("mysql").unwrap();
753 assert_eq!(mysql.hostname, "localhost");
754 assert_eq!(mysql.hostport, 8802);
755 assert_eq!(mysql.charset, "utf8mb4");
756 assert_eq!(mysql.prefix, "sz_");
757
758 let ljclz = config.database.connections.get("ljclz").unwrap();
760 assert_eq!(ljclz.charset, "utf8");
761 assert_eq!(ljclz.prefix, "ims_");
762
763 let oceanbase = config.database.connections.get("oceanbase").unwrap();
765 assert_eq!(oceanbase.hostport, 2881);
766 assert_eq!(oceanbase.hostname, "localhost");
767
768 assert_eq!(config.cache.default, "memory");
770 assert!(config.cache.stores.contains_key("memory"));
771
772 assert_eq!(config.addons.addons_path, "addons");
774 assert_eq!(config.addons.priority.p0.len(), 3);
775
776 assert_eq!(config.log.default, "file");
778 assert!(config.log.channels.contains_key("file"));
779
780 assert_eq!(config.server.host, "0.0.0.0");
782 assert_eq!(config.server.port, 8080);
783 }
784 }
785
786 #[test]
788 fn test_load_missing_file_uses_default() {
789 let temp_dir = std::env::temp_dir().join("sz_rust_config_test_missing");
790 let _ = std::fs::create_dir_all(&temp_dir);
791 let config = AppConfig::load_from_dir(&temp_dir).unwrap();
793 assert!(config.app.auto_multi_app);
794 assert_eq!(config.database.default, "mysql");
795 let _ = std::fs::remove_dir_all(&temp_dir);
796 }
797
798 #[test]
800 fn test_env_override_password() {
801 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
803 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
805
806 let mut config = AppConfig::default();
807 config.database.connections.insert(
808 "mysql".to_string(),
809 DatabaseConnection {
810 r#type: "mysql".to_string(),
811 hostname: "localhost".to_string(),
812 database: "test".to_string(),
813 username: "root".to_string(),
814 password: String::new(),
815 hostport: 3306,
816 charset: "utf8mb4".to_string(),
817 prefix: "sz_".to_string(),
818 deploy: 0,
819 rw_separate: false,
820 fields_strict: true,
821 break_reconnect: true,
822 },
823 );
824
825 std::env::set_var("SZ_DB_MYSQL_PASSWORD", "secret123");
827
828 config.apply_env_overrides();
830
831 assert_eq!(config.database.connections["mysql"].password, "secret123");
833
834 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
836 }
837
838 #[test]
840 fn test_env_override_empty_ignored() {
841 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
843 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
845
846 let mut config = AppConfig::default();
847 config.database.connections.insert(
848 "mysql".to_string(),
849 DatabaseConnection {
850 r#type: "mysql".to_string(),
851 hostname: "localhost".to_string(),
852 database: "test".to_string(),
853 username: "root".to_string(),
854 password: "existing".to_string(),
855 hostport: 3306,
856 charset: "utf8mb4".to_string(),
857 prefix: "sz_".to_string(),
858 deploy: 0,
859 rw_separate: false,
860 fields_strict: true,
861 break_reconnect: true,
862 },
863 );
864
865 std::env::set_var("SZ_DB_MYSQL_PASSWORD", "");
867
868 config.apply_env_overrides();
869
870 assert_eq!(config.database.connections["mysql"].password, "existing");
872
873 std::env::remove_var("SZ_DB_MYSQL_PASSWORD");
874 }
875
876 #[test]
878 fn test_default_connection() {
879 let mut config = AppConfig::default();
880 config.database.default = "mysql".to_string();
881 config.database.connections.insert(
882 "mysql".to_string(),
883 DatabaseConnection {
884 r#type: "mysql".to_string(),
885 hostname: "localhost".to_string(),
886 database: "test".to_string(),
887 username: "root".to_string(),
888 password: String::new(),
889 hostport: 3306,
890 charset: "utf8mb4".to_string(),
891 prefix: "sz_".to_string(),
892 deploy: 0,
893 rw_separate: false,
894 fields_strict: true,
895 break_reconnect: true,
896 },
897 );
898
899 let conn = config.default_connection();
900 assert!(conn.is_some());
901 assert_eq!(conn.unwrap().hostname, "localhost");
902 }
903
904 #[test]
906 fn test_default_connection_missing() {
907 let config = AppConfig::default();
908 assert!(config.default_connection().is_none());
909 }
910
911 #[test]
916 fn test_env_override_hostname() {
917 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
918 std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
919
920 let mut config = AppConfig::default();
921 config.database.connections.insert(
922 "mysql".to_string(),
923 DatabaseConnection {
924 r#type: "mysql".to_string(),
925 hostname: "localhost".to_string(),
926 database: "test".to_string(),
927 username: "root".to_string(),
928 password: String::new(),
929 hostport: 3306,
930 charset: "utf8mb4".to_string(),
931 prefix: "sz_".to_string(),
932 deploy: 0,
933 rw_separate: false,
934 fields_strict: true,
935 break_reconnect: true,
936 },
937 );
938
939 std::env::set_var("SZ_DB_MYSQL_HOSTNAME", "10.0.0.5");
940 config.apply_env_overrides();
941
942 assert_eq!(config.database.connections["mysql"].hostname, "10.0.0.5");
943
944 std::env::remove_var("SZ_DB_MYSQL_HOSTNAME");
945 }
946
947 #[test]
949 fn test_env_override_hostport() {
950 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
951 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
952
953 let mut config = AppConfig::default();
954 config.database.connections.insert(
955 "mysql".to_string(),
956 DatabaseConnection {
957 r#type: "mysql".to_string(),
958 hostname: "localhost".to_string(),
959 database: "test".to_string(),
960 username: "root".to_string(),
961 password: String::new(),
962 hostport: 3306,
963 charset: "utf8mb4".to_string(),
964 prefix: "sz_".to_string(),
965 deploy: 0,
966 rw_separate: false,
967 fields_strict: true,
968 break_reconnect: true,
969 },
970 );
971
972 std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "8802");
973 config.apply_env_overrides();
974
975 assert_eq!(config.database.connections["mysql"].hostport, 8802);
976
977 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
978 }
979
980 #[test]
982 fn test_env_override_hostport_invalid_ignored() {
983 let _env_guard = ENV_TEST_LOCK.lock().unwrap();
984 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
985
986 let mut config = AppConfig::default();
987 config.database.connections.insert(
988 "mysql".to_string(),
989 DatabaseConnection {
990 r#type: "mysql".to_string(),
991 hostname: "localhost".to_string(),
992 database: "test".to_string(),
993 username: "root".to_string(),
994 password: String::new(),
995 hostport: 3306,
996 charset: "utf8mb4".to_string(),
997 prefix: "sz_".to_string(),
998 deploy: 0,
999 rw_separate: false,
1000 fields_strict: true,
1001 break_reconnect: true,
1002 },
1003 );
1004
1005 std::env::set_var("SZ_DB_MYSQL_HOSTPORT", "not-a-number");
1006 config.apply_env_overrides();
1007
1008 assert_eq!(config.database.connections["mysql"].hostport, 3306);
1010
1011 std::env::remove_var("SZ_DB_MYSQL_HOSTPORT");
1012 }
1013
1014 #[test]
1016 fn test_parse_error() {
1017 let bad_yaml = "default: mysql\n bad: : : indent";
1018 let result: Result<DatabaseSection, _> = serde_yml::from_str(bad_yaml);
1019 let _ = result;
1022 }
1023
1024 #[test]
1029 fn test_config_watcher_has_changes_false_on_init() {
1030 let dir = std::env::temp_dir().join("sz_rust_watcher_test_init");
1031 let _ = std::fs::create_dir_all(&dir);
1032 std::fs::write(dir.join("app.yml"), "default_app: test\n").unwrap();
1033
1034 let config = AppConfig::load_from_dir(&dir).unwrap();
1035 let shared = Arc::new(parking_lot::RwLock::new(config));
1036 let watcher = ConfigWatcher::new(&dir, shared);
1037
1038 watcher.init_mtimes();
1040
1041 assert!(!watcher.has_changes());
1043
1044 let _ = std::fs::remove_dir_all(&dir);
1045 }
1046
1047 #[test]
1048 fn test_config_watcher_detects_file_modification() {
1049 let dir = std::env::temp_dir().join("sz_rust_watcher_test_modify");
1050 let _ = std::fs::create_dir_all(&dir);
1051 std::fs::write(dir.join("app.yml"), "default_app: before\n").unwrap();
1052
1053 let config = AppConfig::load_from_dir(&dir).unwrap();
1054 let shared = Arc::new(parking_lot::RwLock::new(config));
1055 let watcher = ConfigWatcher::new(&dir, shared);
1056
1057 watcher.init_mtimes();
1058 assert!(!watcher.has_changes());
1059
1060 std::thread::sleep(std::time::Duration::from_millis(50));
1062 std::fs::write(dir.join("app.yml"), "default_app: after\n").unwrap();
1063
1064 assert!(watcher.has_changes());
1066
1067 watcher.update_mtimes();
1069 assert!(!watcher.has_changes());
1070
1071 let _ = std::fs::remove_dir_all(&dir);
1072 }
1073
1074 #[test]
1075 fn test_config_watcher_detects_new_file() {
1076 let dir = std::env::temp_dir().join("sz_rust_watcher_test_new");
1077 let _ = std::fs::create_dir_all(&dir);
1078
1079 let config = AppConfig::load_from_dir(&dir).unwrap();
1080 let shared = Arc::new(parking_lot::RwLock::new(config));
1081 let watcher = ConfigWatcher::new(&dir, shared);
1082
1083 watcher.init_mtimes();
1084
1085 std::fs::write(dir.join("app.yml"), "default_app: new\n").unwrap();
1087
1088 assert!(watcher.has_changes());
1090
1091 let _ = std::fs::remove_dir_all(&dir);
1092 }
1093
1094 #[tokio::test]
1095 async fn test_config_watcher_hot_reload() {
1096 let dir = std::env::temp_dir().join("sz_rust_watcher_test_hot");
1097 let _ = std::fs::create_dir_all(&dir);
1098 std::fs::write(dir.join("app.yml"), "default_app: before\n").unwrap();
1099
1100 let config = AppConfig::load_from_dir(&dir).unwrap();
1101 let shared = Arc::new(parking_lot::RwLock::new(config));
1102 let watcher = ConfigWatcher::new(&dir, shared.clone()).with_poll_interval(1); let handle = watcher.start();
1105
1106 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1108
1109 std::thread::sleep(std::time::Duration::from_millis(100));
1111 std::fs::write(dir.join("app.yml"), "default_app: hot_reloaded\n").unwrap();
1112
1113 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
1115
1116 let current = shared.read().clone();
1118 assert_eq!(current.app.default_app, "hot_reloaded");
1119
1120 handle.stop();
1121 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1122
1123 let _ = std::fs::remove_dir_all(&dir);
1124 }
1125}