1use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9#[serde(default)]
10pub struct Config {
11 pub server: ServerConfig,
13 pub storage: StorageConfig,
15 #[serde(default)]
17 pub security: SecurityConfig,
18 #[serde(default)]
20 pub devices: Vec<DeviceConfig>,
21 #[serde(default)]
23 pub prometheus: PrometheusConfig,
24 #[serde(default)]
26 pub mqtt: MqttConfig,
27}
28
29impl Config {
30 pub fn load_default() -> Result<Self, ConfigError> {
32 let path = default_config_path();
33 if path.exists() {
34 Self::load(&path)
35 } else {
36 Ok(Self::default())
37 }
38 }
39
40 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
42 let content = std::fs::read_to_string(path.as_ref()).map_err(|e| ConfigError::Read {
43 path: path.as_ref().to_path_buf(),
44 source: e,
45 })?;
46 toml::from_str(&content).map_err(|e| ConfigError::Parse {
47 path: path.as_ref().to_path_buf(),
48 source: e,
49 })
50 }
51
52 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), ConfigError> {
54 let content = toml::to_string_pretty(self).map_err(ConfigError::Serialize)?;
55
56 if let Some(parent) = path.as_ref().parent() {
58 std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
59 path: parent.to_path_buf(),
60 source: e,
61 })?;
62 }
63
64 std::fs::write(path.as_ref(), content).map_err(|e| ConfigError::Write {
65 path: path.as_ref().to_path_buf(),
66 source: e,
67 })
68 }
69
70 pub fn validate(&self) -> Result<(), ConfigError> {
88 let mut errors = Vec::new();
89
90 errors.extend(self.server.validate());
92
93 errors.extend(self.storage.validate());
95
96 errors.extend(self.security.validate());
98
99 let mut seen_addresses = std::collections::HashSet::new();
101 for (i, device) in self.devices.iter().enumerate() {
102 let prefix = format!("devices[{}]", i);
103 errors.extend(device.validate(&prefix));
104
105 let addr_lower = device.address.to_lowercase();
107 if !seen_addresses.insert(addr_lower.clone()) {
108 errors.push(ValidationError {
109 field: format!("{}.address", prefix),
110 message: format!("duplicate device address '{}'", device.address),
111 });
112 }
113 }
114
115 errors.extend(self.prometheus.validate());
117
118 errors.extend(self.mqtt.validate());
120
121 if errors.is_empty() {
122 Ok(())
123 } else {
124 Err(ConfigError::Validation(errors))
125 }
126 }
127
128 pub fn load_validated<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
132 let config = Self::load(path)?;
133 config.validate()?;
134 Ok(config)
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141pub struct ServerConfig {
142 pub bind: String,
144 #[serde(default = "default_broadcast_buffer")]
152 pub broadcast_buffer: usize,
153}
154
155pub const DEFAULT_BROADCAST_BUFFER: usize = 100;
157
158fn default_broadcast_buffer() -> usize {
159 DEFAULT_BROADCAST_BUFFER
160}
161
162impl Default for ServerConfig {
163 fn default() -> Self {
164 Self {
165 bind: "127.0.0.1:8080".to_string(),
166 broadcast_buffer: DEFAULT_BROADCAST_BUFFER,
167 }
168 }
169}
170
171impl ServerConfig {
172 pub fn validate(&self) -> Vec<ValidationError> {
174 let mut errors = Vec::new();
175
176 if self.bind.is_empty() {
177 errors.push(ValidationError {
178 field: "server.bind".to_string(),
179 message: "bind address cannot be empty".to_string(),
180 });
181 } else {
182 let parts: Vec<&str> = self.bind.rsplitn(2, ':').collect();
184 if parts.len() != 2 {
185 errors.push(ValidationError {
186 field: "server.bind".to_string(),
187 message: format!(
188 "invalid bind address '{}': expected format 'host:port'",
189 self.bind
190 ),
191 });
192 } else {
193 let port_str = parts[0];
195 match port_str.parse::<u16>() {
196 Ok(0) => {
197 errors.push(ValidationError {
198 field: "server.bind".to_string(),
199 message: "port cannot be 0".to_string(),
200 });
201 }
202 Err(_) => {
203 errors.push(ValidationError {
204 field: "server.bind".to_string(),
205 message: format!(
206 "invalid port '{}': must be a number 1-65535",
207 port_str
208 ),
209 });
210 }
211 Ok(_) => {} }
213 }
214 }
215
216 errors
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(default)]
223pub struct StorageConfig {
224 pub path: PathBuf,
226}
227
228impl Default for StorageConfig {
229 fn default() -> Self {
230 Self {
231 path: aranet_store::default_db_path(),
232 }
233 }
234}
235
236impl StorageConfig {
237 pub fn validate(&self) -> Vec<ValidationError> {
239 let mut errors = Vec::new();
240
241 if self.path.as_os_str().is_empty() {
242 errors.push(ValidationError {
243 field: "storage.path".to_string(),
244 message: "database path cannot be empty".to_string(),
245 });
246 }
247
248 errors
249 }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(default)]
255pub struct SecurityConfig {
256 pub api_key_enabled: bool,
259 pub api_key: Option<String>,
262 pub rate_limit_enabled: bool,
264 #[serde(default = "default_rate_limit_requests")]
266 pub rate_limit_requests: u32,
267 #[serde(default = "default_rate_limit_window")]
269 pub rate_limit_window_secs: u64,
270}
271
272fn default_rate_limit_requests() -> u32 {
273 100
274}
275
276fn default_rate_limit_window() -> u64 {
277 60
278}
279
280impl Default for SecurityConfig {
281 fn default() -> Self {
282 Self {
283 api_key_enabled: false,
284 api_key: None,
285 rate_limit_enabled: true,
287 rate_limit_requests: default_rate_limit_requests(),
288 rate_limit_window_secs: default_rate_limit_window(),
289 }
290 }
291}
292
293impl SecurityConfig {
294 pub fn validate(&self) -> Vec<ValidationError> {
296 let mut errors = Vec::new();
297
298 if self.api_key_enabled {
299 match &self.api_key {
300 None => {
301 errors.push(ValidationError {
302 field: "security.api_key".to_string(),
303 message: "API key must be set when authentication is enabled".to_string(),
304 });
305 }
306 Some(key) if key.len() < 32 => {
307 errors.push(ValidationError {
308 field: "security.api_key".to_string(),
309 message: "API key must be at least 32 characters for security".to_string(),
310 });
311 }
312 _ => {}
313 }
314 }
315
316 if self.rate_limit_enabled {
317 if self.rate_limit_requests == 0 {
318 errors.push(ValidationError {
319 field: "security.rate_limit_requests".to_string(),
320 message: "rate limit requests must be greater than 0".to_string(),
321 });
322 }
323 if self.rate_limit_window_secs < 1 {
324 errors.push(ValidationError {
325 field: "security.rate_limit_window_secs".to_string(),
326 message: "rate limit window must be at least 1 second".to_string(),
327 });
328 }
329 }
330
331 errors
332 }
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(default)]
338pub struct PrometheusConfig {
339 pub enabled: bool,
341 pub push_gateway: Option<String>,
344 #[serde(default = "default_push_interval")]
346 pub push_interval: u64,
347}
348
349fn default_push_interval() -> u64 {
350 60
351}
352
353impl Default for PrometheusConfig {
354 fn default() -> Self {
355 Self {
356 enabled: false,
357 push_gateway: None,
358 push_interval: default_push_interval(),
359 }
360 }
361}
362
363impl PrometheusConfig {
364 pub fn validate(&self) -> Vec<ValidationError> {
366 let mut errors = Vec::new();
367
368 if let Some(url) = &self.push_gateway
369 && url.is_empty()
370 {
371 errors.push(ValidationError {
372 field: "prometheus.push_gateway".to_string(),
373 message: "push gateway URL cannot be empty (use null/omit instead)".to_string(),
374 });
375 }
376
377 if self.push_interval < 10 {
378 errors.push(ValidationError {
379 field: "prometheus.push_interval".to_string(),
380 message: format!(
381 "push interval {} is too short (minimum 10 seconds)",
382 self.push_interval
383 ),
384 });
385 }
386
387 errors
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(default)]
394pub struct MqttConfig {
395 pub enabled: bool,
397 pub broker: String,
399 #[serde(default = "default_topic_prefix")]
401 pub topic_prefix: String,
402 #[serde(default = "default_client_id")]
404 pub client_id: String,
405 #[serde(default = "default_qos")]
407 pub qos: u8,
408 #[serde(default)]
410 pub retain: bool,
411 pub username: Option<String>,
413 pub password: Option<String>,
415 #[serde(default = "default_keep_alive")]
417 pub keep_alive: u64,
418}
419
420fn default_topic_prefix() -> String {
421 "aranet".to_string()
422}
423
424fn default_client_id() -> String {
425 "aranet-service".to_string()
426}
427
428fn default_qos() -> u8 {
429 1
430}
431
432fn default_keep_alive() -> u64 {
433 60
434}
435
436impl Default for MqttConfig {
437 fn default() -> Self {
438 Self {
439 enabled: false,
440 broker: "mqtt://localhost:1883".to_string(),
441 topic_prefix: default_topic_prefix(),
442 client_id: default_client_id(),
443 qos: default_qos(),
444 retain: false,
445 username: None,
446 password: None,
447 keep_alive: default_keep_alive(),
448 }
449 }
450}
451
452impl MqttConfig {
453 pub fn validate(&self) -> Vec<ValidationError> {
455 let mut errors = Vec::new();
456
457 if self.enabled {
458 if self.broker.is_empty() {
459 errors.push(ValidationError {
460 field: "mqtt.broker".to_string(),
461 message: "broker URL cannot be empty when MQTT is enabled".to_string(),
462 });
463 } else if !self.broker.starts_with("mqtt://") && !self.broker.starts_with("mqtts://") {
464 errors.push(ValidationError {
465 field: "mqtt.broker".to_string(),
466 message: format!(
467 "invalid broker URL '{}': must start with mqtt:// or mqtts://",
468 self.broker
469 ),
470 });
471 }
472
473 if self.topic_prefix.is_empty() {
474 errors.push(ValidationError {
475 field: "mqtt.topic_prefix".to_string(),
476 message: "topic prefix cannot be empty".to_string(),
477 });
478 }
479
480 if self.client_id.is_empty() {
481 errors.push(ValidationError {
482 field: "mqtt.client_id".to_string(),
483 message: "client ID cannot be empty".to_string(),
484 });
485 }
486
487 if self.qos > 2 {
488 errors.push(ValidationError {
489 field: "mqtt.qos".to_string(),
490 message: format!("invalid QoS level {}: must be 0, 1, or 2", self.qos),
491 });
492 }
493
494 if self.keep_alive < 5 {
495 errors.push(ValidationError {
496 field: "mqtt.keep_alive".to_string(),
497 message: format!(
498 "keep-alive interval {} is too short (minimum 5 seconds)",
499 self.keep_alive
500 ),
501 });
502 }
503 }
504
505 errors
506 }
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct DeviceConfig {
512 pub address: String,
514 #[serde(default)]
516 pub alias: Option<String>,
517 #[serde(default = "default_poll_interval")]
519 pub poll_interval: u64,
520}
521
522pub const MIN_POLL_INTERVAL: u64 = 10;
524pub const MAX_POLL_INTERVAL: u64 = 3600;
526
527fn default_poll_interval() -> u64 {
528 60
529}
530
531impl DeviceConfig {
532 pub fn validate(&self, prefix: &str) -> Vec<ValidationError> {
534 let mut errors = Vec::new();
535
536 if self.address.is_empty() {
538 errors.push(ValidationError {
539 field: format!("{}.address", prefix),
540 message: "device address cannot be empty".to_string(),
541 });
542 } else if self.address.len() < 3 {
543 errors.push(ValidationError {
544 field: format!("{}.address", prefix),
545 message: format!(
546 "device address '{}' is too short (minimum 3 characters)",
547 self.address
548 ),
549 });
550 }
551
552 if let Some(alias) = &self.alias
554 && alias.is_empty()
555 {
556 errors.push(ValidationError {
557 field: format!("{}.alias", prefix),
558 message: "alias cannot be empty string (use null/omit instead)".to_string(),
559 });
560 }
561
562 if self.poll_interval < MIN_POLL_INTERVAL {
564 errors.push(ValidationError {
565 field: format!("{}.poll_interval", prefix),
566 message: format!(
567 "poll interval {} is too short (minimum {} seconds)",
568 self.poll_interval, MIN_POLL_INTERVAL
569 ),
570 });
571 } else if self.poll_interval > MAX_POLL_INTERVAL {
572 errors.push(ValidationError {
573 field: format!("{}.poll_interval", prefix),
574 message: format!(
575 "poll interval {} is too long (maximum {} seconds / 1 hour)",
576 self.poll_interval, MAX_POLL_INTERVAL
577 ),
578 });
579 }
580
581 errors
582 }
583}
584
585#[derive(Debug, thiserror::Error)]
587pub enum ConfigError {
588 #[error("Failed to read config file {path}: {source}")]
589 Read {
590 path: PathBuf,
591 source: std::io::Error,
592 },
593 #[error("Failed to parse config file {path}: {source}")]
594 Parse {
595 path: PathBuf,
596 source: toml::de::Error,
597 },
598 #[error("Failed to serialize config: {0}")]
599 Serialize(toml::ser::Error),
600 #[error("Failed to write config file {path}: {source}")]
601 Write {
602 path: PathBuf,
603 source: std::io::Error,
604 },
605 #[error("Configuration validation failed:\n{}", format_validation_errors(.0))]
606 Validation(Vec<ValidationError>),
607}
608
609#[derive(Debug, Clone)]
611pub struct ValidationError {
612 pub field: String,
614 pub message: String,
616}
617
618impl std::fmt::Display for ValidationError {
619 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620 write!(f, "{}: {}", self.field, self.message)
621 }
622}
623
624fn format_validation_errors(errors: &[ValidationError]) -> String {
625 errors
626 .iter()
627 .map(|e| format!(" - {}", e))
628 .collect::<Vec<_>>()
629 .join("\n")
630}
631
632pub fn default_config_path() -> PathBuf {
634 dirs::config_dir()
635 .unwrap_or_else(|| PathBuf::from("."))
636 .join("aranet")
637 .join("server.toml")
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn test_config_default() {
646 let config = Config::default();
647 assert_eq!(config.server.bind, "127.0.0.1:8080");
648 assert!(config.devices.is_empty());
649 }
650
651 #[test]
652 fn test_server_config_default() {
653 let config = ServerConfig::default();
654 assert_eq!(config.bind, "127.0.0.1:8080");
655 }
656
657 #[test]
658 fn test_storage_config_default() {
659 let config = StorageConfig::default();
660 assert_eq!(config.path, aranet_store::default_db_path());
661 }
662
663 #[test]
664 fn test_device_config_serde() {
665 let toml = r#"
666 address = "AA:BB:CC:DD:EE:FF"
667 alias = "Living Room"
668 poll_interval = 120
669 "#;
670 let config: DeviceConfig = toml::from_str(toml).unwrap();
671 assert_eq!(config.address, "AA:BB:CC:DD:EE:FF");
672 assert_eq!(config.alias, Some("Living Room".to_string()));
673 assert_eq!(config.poll_interval, 120);
674 }
675
676 #[test]
677 fn test_device_config_default_poll_interval() {
678 let toml = r#"address = "AA:BB:CC:DD:EE:FF""#;
679 let config: DeviceConfig = toml::from_str(toml).unwrap();
680 assert_eq!(config.poll_interval, 60);
681 assert_eq!(config.alias, None);
682 }
683
684 #[test]
685 fn test_config_save_and_load() {
686 let temp_dir = tempfile::tempdir().unwrap();
687 let config_path = temp_dir.path().join("test_config.toml");
688
689 let config = Config {
690 server: ServerConfig {
691 bind: "0.0.0.0:9090".to_string(),
692 ..Default::default()
693 },
694 storage: StorageConfig {
695 path: PathBuf::from("/tmp/test.db"),
696 },
697 devices: vec![DeviceConfig {
698 address: "AA:BB:CC:DD:EE:FF".to_string(),
699 alias: Some("Test Device".to_string()),
700 poll_interval: 30,
701 }],
702 ..Default::default()
703 };
704
705 config.save(&config_path).unwrap();
706 let loaded = Config::load(&config_path).unwrap();
707
708 assert_eq!(loaded.server.bind, "0.0.0.0:9090");
709 assert_eq!(loaded.storage.path, PathBuf::from("/tmp/test.db"));
710 assert_eq!(loaded.devices.len(), 1);
711 assert_eq!(loaded.devices[0].address, "AA:BB:CC:DD:EE:FF");
712 assert_eq!(loaded.devices[0].alias, Some("Test Device".to_string()));
713 assert_eq!(loaded.devices[0].poll_interval, 30);
714 }
715
716 #[test]
717 fn test_config_load_nonexistent() {
718 let result = Config::load("/nonexistent/path/config.toml");
719 assert!(matches!(result, Err(ConfigError::Read { .. })));
720 }
721
722 #[test]
723 fn test_config_load_invalid_toml() {
724 let temp_dir = tempfile::tempdir().unwrap();
725 let config_path = temp_dir.path().join("invalid.toml");
726 std::fs::write(&config_path, "this is not valid { toml").unwrap();
727
728 let result = Config::load(&config_path);
729 assert!(matches!(result, Err(ConfigError::Parse { .. })));
730 }
731
732 #[test]
733 fn test_config_full_toml() {
734 let toml = r#"
735 [server]
736 bind = "192.168.1.1:8888"
737
738 [storage]
739 path = "/data/aranet.db"
740
741 [[devices]]
742 address = "AA:BB:CC:DD:EE:FF"
743 alias = "Living Room"
744 poll_interval = 60
745
746 [[devices]]
747 address = "11:22:33:44:55:66"
748 poll_interval = 120
749 "#;
750
751 let config: Config = toml::from_str(toml).unwrap();
752 assert_eq!(config.server.bind, "192.168.1.1:8888");
753 assert_eq!(config.storage.path, PathBuf::from("/data/aranet.db"));
754 assert_eq!(config.devices.len(), 2);
755 assert_eq!(config.devices[0].alias, Some("Living Room".to_string()));
756 assert_eq!(config.devices[1].alias, None);
757 }
758
759 #[test]
760 fn test_default_config_path() {
761 let path = default_config_path();
762 assert!(path.ends_with("aranet/server.toml"));
763 }
764
765 #[test]
766 fn test_config_error_display() {
767 let error = ConfigError::Read {
768 path: PathBuf::from("/test/path"),
769 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
770 };
771 let display = format!("{}", error);
772 assert!(display.contains("/test/path"));
773 assert!(display.contains("not found"));
774 }
775
776 #[test]
781 fn test_default_config_validates() {
782 let config = Config::default();
783 assert!(config.validate().is_ok());
784 }
785
786 #[test]
787 fn test_server_bind_validation() {
788 let valid = ServerConfig {
790 bind: "127.0.0.1:8080".to_string(),
791 ..Default::default()
792 };
793 assert!(valid.validate().is_empty());
794
795 let valid_ipv6 = ServerConfig {
796 bind: "[::1]:8080".to_string(),
797 ..Default::default()
798 };
799 assert!(valid_ipv6.validate().is_empty());
800
801 let valid_hostname = ServerConfig {
802 bind: "localhost:8080".to_string(),
803 ..Default::default()
804 };
805 assert!(valid_hostname.validate().is_empty());
806
807 let empty = ServerConfig {
809 bind: "".to_string(),
810 ..Default::default()
811 };
812 let errors = empty.validate();
813 assert_eq!(errors.len(), 1);
814 assert!(errors[0].message.contains("cannot be empty"));
815
816 let no_port = ServerConfig {
818 bind: "127.0.0.1".to_string(),
819 ..Default::default()
820 };
821 let errors = no_port.validate();
822 assert_eq!(errors.len(), 1);
823 assert!(errors[0].message.contains("host:port"));
824
825 let port_zero = ServerConfig {
827 bind: "127.0.0.1:0".to_string(),
828 ..Default::default()
829 };
830 let errors = port_zero.validate();
831 assert_eq!(errors.len(), 1);
832 assert!(errors[0].message.contains("cannot be 0"));
833
834 let bad_port = ServerConfig {
836 bind: "127.0.0.1:abc".to_string(),
837 ..Default::default()
838 };
839 let errors = bad_port.validate();
840 assert_eq!(errors.len(), 1);
841 assert!(errors[0].message.contains("must be a number"));
842 }
843
844 #[test]
845 fn test_storage_path_validation() {
846 let valid = StorageConfig {
848 path: PathBuf::from("/data/aranet.db"),
849 };
850 assert!(valid.validate().is_empty());
851
852 let empty = StorageConfig {
854 path: PathBuf::new(),
855 };
856 let errors = empty.validate();
857 assert_eq!(errors.len(), 1);
858 assert!(errors[0].message.contains("cannot be empty"));
859 }
860
861 #[test]
862 fn test_device_config_validation() {
863 let valid = DeviceConfig {
865 address: "AA:BB:CC:DD:EE:FF".to_string(),
866 alias: Some("Living Room".to_string()),
867 poll_interval: 60,
868 };
869 assert!(valid.validate("devices[0]").is_empty());
870
871 let empty_addr = DeviceConfig {
873 address: "".to_string(),
874 alias: None,
875 poll_interval: 60,
876 };
877 let errors = empty_addr.validate("devices[0]");
878 assert_eq!(errors.len(), 1);
879 assert!(errors[0].message.contains("cannot be empty"));
880
881 let short_addr = DeviceConfig {
883 address: "AB".to_string(),
884 alias: None,
885 poll_interval: 60,
886 };
887 let errors = short_addr.validate("devices[0]");
888 assert_eq!(errors.len(), 1);
889 assert!(errors[0].message.contains("too short"));
890
891 let empty_alias = DeviceConfig {
893 address: "Aranet4 12345".to_string(),
894 alias: Some("".to_string()),
895 poll_interval: 60,
896 };
897 let errors = empty_alias.validate("devices[0]");
898 assert_eq!(errors.len(), 1);
899 assert!(errors[0].message.contains("cannot be empty string"));
900
901 let short_poll = DeviceConfig {
903 address: "Aranet4 12345".to_string(),
904 alias: None,
905 poll_interval: 5,
906 };
907 let errors = short_poll.validate("devices[0]");
908 assert_eq!(errors.len(), 1);
909 assert!(errors[0].message.contains("too short"));
910
911 let long_poll = DeviceConfig {
913 address: "Aranet4 12345".to_string(),
914 alias: None,
915 poll_interval: 7200,
916 };
917 let errors = long_poll.validate("devices[0]");
918 assert_eq!(errors.len(), 1);
919 assert!(errors[0].message.contains("too long"));
920 }
921
922 #[test]
923 fn test_duplicate_device_addresses() {
924 let config = Config {
925 server: ServerConfig::default(),
926 storage: StorageConfig::default(),
927 devices: vec![
928 DeviceConfig {
929 address: "Aranet4 12345".to_string(),
930 alias: Some("Office".to_string()),
931 poll_interval: 60,
932 },
933 DeviceConfig {
934 address: "Aranet4 12345".to_string(), alias: Some("Bedroom".to_string()),
936 poll_interval: 60,
937 },
938 ],
939 ..Default::default()
940 };
941
942 let result = config.validate();
943 assert!(result.is_err());
944 if let Err(ConfigError::Validation(errors)) = result {
945 assert!(errors.iter().any(|e| e.message.contains("duplicate")));
946 }
947 }
948
949 #[test]
950 fn test_duplicate_addresses_case_insensitive() {
951 let config = Config {
952 server: ServerConfig::default(),
953 storage: StorageConfig::default(),
954 devices: vec![
955 DeviceConfig {
956 address: "Aranet4 12345".to_string(),
957 alias: None,
958 poll_interval: 60,
959 },
960 DeviceConfig {
961 address: "ARANET4 12345".to_string(), alias: None,
963 poll_interval: 60,
964 },
965 ],
966 ..Default::default()
967 };
968
969 let result = config.validate();
970 assert!(result.is_err());
971 }
972
973 #[test]
974 fn test_validation_error_display() {
975 let error = ValidationError {
976 field: "server.bind".to_string(),
977 message: "invalid port".to_string(),
978 };
979 assert_eq!(format!("{}", error), "server.bind: invalid port");
980 }
981
982 #[test]
983 fn test_config_validation_error_display() {
984 let errors = vec![
985 ValidationError {
986 field: "server.bind".to_string(),
987 message: "port cannot be 0".to_string(),
988 },
989 ValidationError {
990 field: "devices[0].address".to_string(),
991 message: "cannot be empty".to_string(),
992 },
993 ];
994 let error = ConfigError::Validation(errors);
995 let display = format!("{}", error);
996 assert!(display.contains("server.bind"));
997 assert!(display.contains("devices[0].address"));
998 }
999
1000 #[test]
1005 fn test_prometheus_config_default() {
1006 let config = PrometheusConfig::default();
1007 assert!(!config.enabled);
1008 assert!(config.push_gateway.is_none());
1009 assert_eq!(config.push_interval, 60);
1010 }
1011
1012 #[test]
1013 fn test_prometheus_config_validates() {
1014 let config = PrometheusConfig::default();
1015 assert!(config.validate().is_empty());
1016 }
1017
1018 #[test]
1019 fn test_prometheus_config_empty_push_gateway() {
1020 let config = PrometheusConfig {
1021 enabled: true,
1022 push_gateway: Some("".to_string()),
1023 push_interval: 60,
1024 };
1025 let errors = config.validate();
1026 assert_eq!(errors.len(), 1);
1027 assert!(errors[0].message.contains("cannot be empty"));
1028 }
1029
1030 #[test]
1031 fn test_prometheus_config_short_push_interval() {
1032 let config = PrometheusConfig {
1033 enabled: true,
1034 push_gateway: None,
1035 push_interval: 5,
1036 };
1037 let errors = config.validate();
1038 assert_eq!(errors.len(), 1);
1039 assert!(errors[0].message.contains("too short"));
1040 }
1041
1042 #[test]
1043 fn test_prometheus_config_serde() {
1044 let toml = r#"
1045 enabled = true
1046 push_gateway = "http://localhost:9091"
1047 push_interval = 30
1048 "#;
1049 let config: PrometheusConfig = toml::from_str(toml).unwrap();
1050 assert!(config.enabled);
1051 assert_eq!(
1052 config.push_gateway,
1053 Some("http://localhost:9091".to_string())
1054 );
1055 assert_eq!(config.push_interval, 30);
1056 }
1057
1058 #[test]
1063 fn test_mqtt_config_default() {
1064 let config = MqttConfig::default();
1065 assert!(!config.enabled);
1066 assert_eq!(config.broker, "mqtt://localhost:1883");
1067 assert_eq!(config.topic_prefix, "aranet");
1068 assert_eq!(config.client_id, "aranet-service");
1069 assert_eq!(config.qos, 1);
1070 assert!(!config.retain);
1071 assert!(config.username.is_none());
1072 assert!(config.password.is_none());
1073 assert_eq!(config.keep_alive, 60);
1074 }
1075
1076 #[test]
1077 fn test_mqtt_config_validates_when_disabled() {
1078 let config = MqttConfig::default();
1079 assert!(config.validate().is_empty());
1080 }
1081
1082 #[test]
1083 fn test_mqtt_config_validates_when_enabled() {
1084 let config = MqttConfig {
1085 enabled: true,
1086 ..Default::default()
1087 };
1088 assert!(config.validate().is_empty());
1089 }
1090
1091 #[test]
1092 fn test_mqtt_config_empty_broker() {
1093 let config = MqttConfig {
1094 enabled: true,
1095 broker: "".to_string(),
1096 ..Default::default()
1097 };
1098 let errors = config.validate();
1099 assert!(
1100 errors
1101 .iter()
1102 .any(|e| e.message.contains("broker URL cannot be empty"))
1103 );
1104 }
1105
1106 #[test]
1107 fn test_mqtt_config_invalid_broker_scheme() {
1108 let config = MqttConfig {
1109 enabled: true,
1110 broker: "http://localhost:1883".to_string(),
1111 ..Default::default()
1112 };
1113 let errors = config.validate();
1114 assert!(errors.iter().any(|e| e.message.contains("mqtt://")));
1115 }
1116
1117 #[test]
1118 fn test_mqtt_config_empty_topic_prefix() {
1119 let config = MqttConfig {
1120 enabled: true,
1121 topic_prefix: "".to_string(),
1122 ..Default::default()
1123 };
1124 let errors = config.validate();
1125 assert!(
1126 errors
1127 .iter()
1128 .any(|e| e.message.contains("topic prefix cannot be empty"))
1129 );
1130 }
1131
1132 #[test]
1133 fn test_mqtt_config_empty_client_id() {
1134 let config = MqttConfig {
1135 enabled: true,
1136 client_id: "".to_string(),
1137 ..Default::default()
1138 };
1139 let errors = config.validate();
1140 assert!(
1141 errors
1142 .iter()
1143 .any(|e| e.message.contains("client ID cannot be empty"))
1144 );
1145 }
1146
1147 #[test]
1148 fn test_mqtt_config_invalid_qos() {
1149 let config = MqttConfig {
1150 enabled: true,
1151 qos: 5,
1152 ..Default::default()
1153 };
1154 let errors = config.validate();
1155 assert!(errors.iter().any(|e| e.message.contains("invalid QoS")));
1156 }
1157
1158 #[test]
1159 fn test_mqtt_config_short_keep_alive() {
1160 let config = MqttConfig {
1161 enabled: true,
1162 keep_alive: 2,
1163 ..Default::default()
1164 };
1165 let errors = config.validate();
1166 assert!(
1167 errors
1168 .iter()
1169 .any(|e| e.message.contains("keep-alive interval"))
1170 );
1171 }
1172
1173 #[test]
1174 fn test_mqtt_config_serde() {
1175 let toml = r#"
1176 enabled = true
1177 broker = "mqtts://broker.example.com:8883"
1178 topic_prefix = "home/sensors"
1179 client_id = "my-service"
1180 qos = 2
1181 retain = true
1182 username = "user"
1183 password = "secret"
1184 keep_alive = 30
1185 "#;
1186 let config: MqttConfig = toml::from_str(toml).unwrap();
1187 assert!(config.enabled);
1188 assert_eq!(config.broker, "mqtts://broker.example.com:8883");
1189 assert_eq!(config.topic_prefix, "home/sensors");
1190 assert_eq!(config.client_id, "my-service");
1191 assert_eq!(config.qos, 2);
1192 assert!(config.retain);
1193 assert_eq!(config.username, Some("user".to_string()));
1194 assert_eq!(config.password, Some("secret".to_string()));
1195 assert_eq!(config.keep_alive, 30);
1196 }
1197
1198 #[test]
1199 fn test_config_with_prometheus_and_mqtt() {
1200 let toml = r#"
1201 [server]
1202 bind = "127.0.0.1:8080"
1203
1204 [prometheus]
1205 enabled = true
1206
1207 [mqtt]
1208 enabled = true
1209 broker = "mqtt://localhost:1883"
1210 topic_prefix = "aranet"
1211 "#;
1212 let config: Config = toml::from_str(toml).unwrap();
1213 assert!(config.prometheus.enabled);
1214 assert!(config.mqtt.enabled);
1215 assert!(config.validate().is_ok());
1216 }
1217}