1use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7macro_rules! validate {
9 ($errors:expr, $field:expr, $msg:expr) => {
10 $errors.push(ValidationError {
11 field: $field.to_string(),
12 message: $msg.to_string(),
13 })
14 };
15 ($errors:expr, $field:expr, $($arg:tt)+) => {
16 $errors.push(ValidationError {
17 field: $field.to_string(),
18 message: format!($($arg)+),
19 })
20 };
21}
22
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27 pub server: ServerConfig,
29 pub storage: StorageConfig,
31 #[serde(default)]
33 pub security: SecurityConfig,
34 #[serde(default)]
36 pub devices: Vec<DeviceConfig>,
37 #[serde(default)]
39 pub prometheus: PrometheusConfig,
40 #[serde(default)]
42 pub mqtt: MqttConfig,
43 #[serde(default)]
45 pub notifications: NotificationConfig,
46 #[serde(default)]
48 pub webhooks: WebhookConfig,
49 #[serde(default)]
51 pub influxdb: InfluxDbConfig,
52}
53
54impl Config {
55 pub fn load_default() -> Result<Self, ConfigError> {
57 let path = default_config_path();
58 if path.exists() {
59 Self::load(&path)
60 } else {
61 Ok(Self::default())
62 }
63 }
64
65 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
67 let content = std::fs::read_to_string(path.as_ref()).map_err(|e| ConfigError::Read {
68 path: path.as_ref().to_path_buf(),
69 source: e,
70 })?;
71 toml::from_str(&content).map_err(|e| ConfigError::Parse {
72 path: path.as_ref().to_path_buf(),
73 source: e,
74 })
75 }
76
77 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), ConfigError> {
79 let content = toml::to_string_pretty(self).map_err(ConfigError::Serialize)?;
80
81 if let Some(parent) = path.as_ref().parent() {
83 std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
84 path: parent.to_path_buf(),
85 source: e,
86 })?;
87 }
88
89 std::fs::write(path.as_ref(), content).map_err(|e| ConfigError::Write {
90 path: path.as_ref().to_path_buf(),
91 source: e,
92 })?;
93
94 #[cfg(unix)]
96 {
97 use std::os::unix::fs::PermissionsExt;
98 let perms = std::fs::Permissions::from_mode(0o600);
99 let _ = std::fs::set_permissions(path.as_ref(), perms);
100 }
101
102 Ok(())
103 }
104
105 pub fn validate(&self) -> Result<(), ConfigError> {
123 let mut errors = Vec::new();
124
125 errors.extend(self.server.validate());
127
128 errors.extend(self.storage.validate());
130
131 errors.extend(self.security.validate());
133
134 let mut seen_addresses = std::collections::HashSet::new();
136 for (i, device) in self.devices.iter().enumerate() {
137 let prefix = format!("devices[{}]", i);
138 errors.extend(device.validate(&prefix));
139
140 let addr_lower = device.address.to_lowercase();
142 if !seen_addresses.insert(addr_lower.clone()) {
143 validate!(
144 errors,
145 format!("{}.address", prefix),
146 "duplicate device address '{}'",
147 device.address
148 );
149 }
150 }
151
152 errors.extend(self.prometheus.validate());
154
155 errors.extend(self.mqtt.validate());
157
158 errors.extend(self.webhooks.validate());
160
161 errors.extend(self.influxdb.validate());
163
164 if errors.is_empty() {
165 Ok(())
166 } else {
167 Err(ConfigError::Validation(errors))
168 }
169 }
170
171 pub fn load_validated<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
175 let config = Self::load(path)?;
176 config.validate()?;
177 Ok(config)
178 }
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(default)]
184pub struct ServerConfig {
185 pub bind: String,
187 #[serde(default = "default_broadcast_buffer")]
195 pub broadcast_buffer: usize,
196}
197
198pub const DEFAULT_BROADCAST_BUFFER: usize = 100;
200
201fn default_broadcast_buffer() -> usize {
202 DEFAULT_BROADCAST_BUFFER
203}
204
205impl Default for ServerConfig {
206 fn default() -> Self {
207 Self {
208 bind: "127.0.0.1:8080".to_string(),
209 broadcast_buffer: DEFAULT_BROADCAST_BUFFER,
210 }
211 }
212}
213
214impl ServerConfig {
215 pub fn validate(&self) -> Vec<ValidationError> {
217 let mut errors = Vec::new();
218
219 if self.bind.is_empty() {
220 validate!(errors, "server.bind", "bind address cannot be empty");
221 } else {
222 let parts: Vec<&str> = self.bind.rsplitn(2, ':').collect();
224 if parts.len() != 2 {
225 validate!(
226 errors,
227 "server.bind",
228 "invalid bind address '{}': expected format 'host:port'",
229 self.bind
230 );
231 } else {
232 let port_str = parts[0];
234 match port_str.parse::<u16>() {
235 Ok(0) => {
236 validate!(errors, "server.bind", "port cannot be 0");
237 }
238 Err(_) => {
239 validate!(
240 errors,
241 "server.bind",
242 "invalid port '{}': must be a number 1-65535",
243 port_str
244 );
245 }
246 Ok(_) => {} }
248 }
249 }
250
251 if self.broadcast_buffer == 0 {
252 validate!(
253 errors,
254 "server.broadcast_buffer",
255 "broadcast buffer must be greater than 0"
256 );
257 } else if self.broadcast_buffer > 10_000 {
258 validate!(
259 errors,
260 "server.broadcast_buffer",
261 "broadcast buffer {} exceeds maximum of 10000",
262 self.broadcast_buffer
263 );
264 }
265
266 errors
267 }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(default)]
273pub struct StorageConfig {
274 pub path: PathBuf,
276}
277
278impl Default for StorageConfig {
279 fn default() -> Self {
280 Self {
281 path: aranet_store::default_db_path(),
282 }
283 }
284}
285
286impl StorageConfig {
287 pub fn validate(&self) -> Vec<ValidationError> {
289 let mut errors = Vec::new();
290
291 if self.path.as_os_str().is_empty() {
292 validate!(errors, "storage.path", "database path cannot be empty");
293 }
294
295 errors
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301#[serde(default)]
302pub struct SecurityConfig {
303 pub api_key_enabled: bool,
306 pub api_key: Option<String>,
309 pub rate_limit_enabled: bool,
311 #[serde(default = "default_rate_limit_requests")]
313 pub rate_limit_requests: u32,
314 #[serde(default = "default_rate_limit_window")]
316 pub rate_limit_window_secs: u64,
317 #[serde(default = "default_rate_limit_max_entries")]
322 pub rate_limit_max_entries: usize,
323 #[serde(default = "default_cors_origins")]
330 pub cors_origins: Vec<String>,
331}
332
333fn default_rate_limit_requests() -> u32 {
334 100
335}
336
337fn default_rate_limit_window() -> u64 {
338 60
339}
340
341fn default_rate_limit_max_entries() -> usize {
342 10_000
343}
344
345fn default_cors_origins() -> Vec<String> {
346 vec![
347 "http://localhost".to_string(),
348 "http://127.0.0.1".to_string(),
349 ]
350}
351
352impl Default for SecurityConfig {
353 fn default() -> Self {
354 Self {
355 api_key_enabled: false,
356 api_key: None,
357 rate_limit_enabled: true,
359 rate_limit_requests: default_rate_limit_requests(),
360 rate_limit_window_secs: default_rate_limit_window(),
361 rate_limit_max_entries: default_rate_limit_max_entries(),
362 cors_origins: default_cors_origins(),
363 }
364 }
365}
366
367impl SecurityConfig {
368 pub fn validate(&self) -> Vec<ValidationError> {
370 let mut errors = Vec::new();
371
372 if self.api_key_enabled {
373 match &self.api_key {
374 None => {
375 validate!(
376 errors,
377 "security.api_key",
378 "API key must be set when authentication is enabled"
379 );
380 }
381 Some(key) if key.len() < 32 => {
382 validate!(
383 errors,
384 "security.api_key",
385 "API key must be at least 32 characters for security"
386 );
387 }
388 _ => {}
389 }
390 }
391
392 if self.rate_limit_enabled {
393 if self.rate_limit_requests == 0 {
394 validate!(
395 errors,
396 "security.rate_limit_requests",
397 "rate limit requests must be greater than 0"
398 );
399 }
400 if self.rate_limit_window_secs < 1 {
401 validate!(
402 errors,
403 "security.rate_limit_window_secs",
404 "rate limit window must be at least 1 second"
405 );
406 }
407 }
408
409 errors
410 }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(default)]
416pub struct PrometheusConfig {
417 pub enabled: bool,
419 pub push_gateway: Option<String>,
422 #[serde(default = "default_push_interval")]
424 pub push_interval: u64,
425}
426
427fn default_push_interval() -> u64 {
428 60
429}
430
431impl Default for PrometheusConfig {
432 fn default() -> Self {
433 Self {
434 enabled: false,
435 push_gateway: None,
436 push_interval: default_push_interval(),
437 }
438 }
439}
440
441impl PrometheusConfig {
442 pub fn validate(&self) -> Vec<ValidationError> {
444 let mut errors = Vec::new();
445
446 if let Some(url) = &self.push_gateway
447 && url.is_empty()
448 {
449 validate!(
450 errors,
451 "prometheus.push_gateway",
452 "push gateway URL cannot be empty (use null/omit instead)"
453 );
454 }
455
456 if self.push_interval < 10 {
457 validate!(
458 errors,
459 "prometheus.push_interval",
460 "push interval {} is too short (minimum 10 seconds)",
461 self.push_interval
462 );
463 }
464
465 errors
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(default)]
472pub struct MqttConfig {
473 pub enabled: bool,
475 pub broker: String,
477 #[serde(default = "default_topic_prefix")]
479 pub topic_prefix: String,
480 #[serde(default = "default_client_id")]
482 pub client_id: String,
483 #[serde(default = "default_qos")]
485 pub qos: u8,
486 #[serde(default)]
488 pub retain: bool,
489 pub username: Option<String>,
491 pub password: Option<String>,
493 #[serde(default = "default_keep_alive")]
495 pub keep_alive: u64,
496 #[serde(default)]
500 pub homeassistant: bool,
501 #[serde(default = "default_ha_discovery_prefix")]
503 pub ha_discovery_prefix: String,
504}
505
506fn default_topic_prefix() -> String {
507 "aranet".to_string()
508}
509
510fn default_client_id() -> String {
511 "aranet-service".to_string()
512}
513
514fn default_qos() -> u8 {
515 1
516}
517
518fn default_keep_alive() -> u64 {
519 60
520}
521
522fn default_ha_discovery_prefix() -> String {
523 "homeassistant".to_string()
524}
525
526impl Default for MqttConfig {
527 fn default() -> Self {
528 Self {
529 enabled: false,
530 broker: "mqtt://localhost:1883".to_string(),
531 topic_prefix: default_topic_prefix(),
532 client_id: default_client_id(),
533 qos: default_qos(),
534 retain: false,
535 username: None,
536 password: None,
537 keep_alive: default_keep_alive(),
538 homeassistant: false,
539 ha_discovery_prefix: default_ha_discovery_prefix(),
540 }
541 }
542}
543
544impl MqttConfig {
545 pub fn validate(&self) -> Vec<ValidationError> {
547 let mut errors = Vec::new();
548
549 if self.enabled {
550 if self.broker.is_empty() {
551 validate!(
552 errors,
553 "mqtt.broker",
554 "broker URL cannot be empty when MQTT is enabled"
555 );
556 } else if !self.broker.starts_with("mqtt://") && !self.broker.starts_with("mqtts://") {
557 validate!(
558 errors,
559 "mqtt.broker",
560 "invalid broker URL '{}': must start with mqtt:// or mqtts://",
561 self.broker
562 );
563 }
564
565 if self.topic_prefix.is_empty() {
566 validate!(errors, "mqtt.topic_prefix", "topic prefix cannot be empty");
567 }
568
569 if self.client_id.is_empty() {
570 validate!(errors, "mqtt.client_id", "client ID cannot be empty");
571 }
572
573 if self.qos > 2 {
574 validate!(
575 errors,
576 "mqtt.qos",
577 "invalid QoS level {}: must be 0, 1, or 2",
578 self.qos
579 );
580 }
581
582 if self.keep_alive < 5 {
583 validate!(
584 errors,
585 "mqtt.keep_alive",
586 "keep-alive interval {} is too short (minimum 5 seconds)",
587 self.keep_alive
588 );
589 }
590 }
591
592 errors
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct DeviceConfig {
599 pub address: String,
601 #[serde(default)]
603 pub alias: Option<String>,
604 #[serde(default = "default_poll_interval")]
606 pub poll_interval: u64,
607}
608
609pub const MIN_POLL_INTERVAL: u64 = 10;
611pub const MAX_POLL_INTERVAL: u64 = 3600;
613
614fn default_poll_interval() -> u64 {
615 60
616}
617
618impl DeviceConfig {
619 pub fn validate(&self, prefix: &str) -> Vec<ValidationError> {
621 let mut errors = Vec::new();
622
623 if self.address.is_empty() {
625 validate!(
626 errors,
627 format!("{}.address", prefix),
628 "device address cannot be empty"
629 );
630 } else if self.address.len() < 3 {
631 validate!(
632 errors,
633 format!("{}.address", prefix),
634 "device address '{}' is too short (minimum 3 characters)",
635 self.address
636 );
637 }
638
639 if let Some(alias) = &self.alias
641 && alias.is_empty()
642 {
643 validate!(
644 errors,
645 format!("{}.alias", prefix),
646 "alias cannot be empty string (use null/omit instead)"
647 );
648 }
649
650 if self.poll_interval < MIN_POLL_INTERVAL {
652 validate!(
653 errors,
654 format!("{}.poll_interval", prefix),
655 "poll interval {} is too short (minimum {} seconds)",
656 self.poll_interval,
657 MIN_POLL_INTERVAL
658 );
659 } else if self.poll_interval > MAX_POLL_INTERVAL {
660 validate!(
661 errors,
662 format!("{}.poll_interval", prefix),
663 "poll interval {} is too long (maximum {} seconds / 1 hour)",
664 self.poll_interval,
665 MAX_POLL_INTERVAL
666 );
667 }
668
669 errors
670 }
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
675#[serde(default)]
676pub struct NotificationConfig {
677 pub enabled: bool,
679 #[serde(default = "default_co2_threshold")]
681 pub co2_threshold: u16,
682 #[serde(default = "default_radon_threshold")]
684 pub radon_threshold: u32,
685 #[serde(default = "default_notification_cooldown")]
687 pub cooldown_secs: u64,
688}
689
690fn default_co2_threshold() -> u16 {
691 1000
692}
693
694fn default_radon_threshold() -> u32 {
695 300
696}
697
698fn default_notification_cooldown() -> u64 {
699 300
700}
701
702impl Default for NotificationConfig {
703 fn default() -> Self {
704 Self {
705 enabled: false,
706 co2_threshold: default_co2_threshold(),
707 radon_threshold: default_radon_threshold(),
708 cooldown_secs: default_notification_cooldown(),
709 }
710 }
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
715#[serde(default)]
716pub struct WebhookConfig {
717 pub enabled: bool,
719 #[serde(default = "default_co2_threshold")]
721 pub co2_threshold: u16,
722 #[serde(default = "default_radon_threshold")]
724 pub radon_threshold: u32,
725 #[serde(default = "default_battery_threshold")]
727 pub battery_threshold: u8,
728 #[serde(default = "default_webhook_cooldown")]
730 pub cooldown_secs: u64,
731 #[serde(default)]
733 pub endpoints: Vec<WebhookEndpoint>,
734}
735
736fn default_battery_threshold() -> u8 {
737 10
738}
739
740fn default_webhook_cooldown() -> u64 {
741 300
742}
743
744impl Default for WebhookConfig {
745 fn default() -> Self {
746 Self {
747 enabled: false,
748 co2_threshold: default_co2_threshold(),
749 radon_threshold: default_radon_threshold(),
750 battery_threshold: default_battery_threshold(),
751 cooldown_secs: default_webhook_cooldown(),
752 endpoints: Vec::new(),
753 }
754 }
755}
756
757impl WebhookConfig {
758 pub fn validate(&self) -> Vec<ValidationError> {
760 let mut errors = Vec::new();
761
762 if self.enabled && self.endpoints.is_empty() {
763 validate!(
764 errors,
765 "webhooks.endpoints",
766 "at least one endpoint must be configured when webhooks are enabled"
767 );
768 }
769
770 for (i, endpoint) in self.endpoints.iter().enumerate() {
771 let prefix = format!("webhooks.endpoints[{}]", i);
772 if endpoint.url.is_empty() {
773 validate!(errors, format!("{}.url", prefix), "URL cannot be empty");
774 } else if !endpoint.url.starts_with("http://") && !endpoint.url.starts_with("https://")
775 {
776 validate!(
777 errors,
778 format!("{}.url", prefix),
779 "URL must start with http:// or https://"
780 );
781 }
782 if endpoint.events.is_empty() {
783 validate!(
784 errors,
785 format!("{}.events", prefix),
786 "at least one event type must be specified"
787 );
788 }
789 for event in &endpoint.events {
790 if !["co2_high", "radon_high", "battery_low"].contains(&event.as_str()) {
791 validate!(
792 errors,
793 format!("{}.events", prefix),
794 "unknown event type '{}' (valid: co2_high, radon_high, battery_low)",
795 event
796 );
797 }
798 }
799 }
800
801 if self.cooldown_secs < 10 {
802 validate!(
803 errors,
804 "webhooks.cooldown_secs",
805 "cooldown {} is too short (minimum 10 seconds)",
806 self.cooldown_secs
807 );
808 }
809
810 errors
811 }
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize)]
816pub struct WebhookEndpoint {
817 pub url: String,
819 pub events: Vec<String>,
822 #[serde(default)]
824 pub headers: std::collections::HashMap<String, String>,
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize)]
829#[serde(default)]
830pub struct InfluxDbConfig {
831 pub enabled: bool,
833 pub url: String,
835 pub token: Option<String>,
837 pub org: String,
839 pub bucket: String,
841 #[serde(default = "default_influxdb_measurement")]
843 pub measurement: String,
844 #[serde(default = "default_influxdb_precision")]
846 pub precision: String,
847}
848
849fn default_influxdb_measurement() -> String {
850 "aranet".to_string()
851}
852
853fn default_influxdb_precision() -> String {
854 "s".to_string()
855}
856
857impl Default for InfluxDbConfig {
858 fn default() -> Self {
859 Self {
860 enabled: false,
861 url: "http://localhost:8086".to_string(),
862 token: None,
863 org: String::new(),
864 bucket: "aranet".to_string(),
865 measurement: default_influxdb_measurement(),
866 precision: default_influxdb_precision(),
867 }
868 }
869}
870
871impl InfluxDbConfig {
872 pub fn validate(&self) -> Vec<ValidationError> {
874 let mut errors = Vec::new();
875
876 if self.enabled {
877 if self.url.trim().is_empty() {
878 validate!(
879 errors,
880 "influxdb.url",
881 "URL cannot be empty when InfluxDB export is enabled"
882 );
883 }
884 if self.org.trim().is_empty() {
885 validate!(
886 errors,
887 "influxdb.org",
888 "organization cannot be empty when InfluxDB export is enabled"
889 );
890 }
891 if self.bucket.trim().is_empty() {
892 validate!(errors, "influxdb.bucket", "bucket name cannot be empty");
893 }
894 if self.measurement.trim().is_empty() {
895 validate!(
896 errors,
897 "influxdb.measurement",
898 "measurement name cannot be empty"
899 );
900 }
901 if !["s", "ms", "us", "ns"].contains(&self.precision.as_str()) {
902 validate!(
903 errors,
904 "influxdb.precision",
905 "invalid precision '{}' (valid: s, ms, us, ns)",
906 self.precision
907 );
908 }
909 }
910
911 errors
912 }
913}
914
915#[derive(Debug, thiserror::Error)]
917pub enum ConfigError {
918 #[error("Failed to read config file {path}: {source}")]
919 Read {
920 path: PathBuf,
921 source: std::io::Error,
922 },
923 #[error("Failed to parse config file {path}: {source}")]
924 Parse {
925 path: PathBuf,
926 source: toml::de::Error,
927 },
928 #[error("Failed to serialize config: {0}")]
929 Serialize(toml::ser::Error),
930 #[error("Failed to write config file {path}: {source}")]
931 Write {
932 path: PathBuf,
933 source: std::io::Error,
934 },
935 #[error("Configuration validation failed:\n{}", format_validation_errors(.0))]
936 Validation(Vec<ValidationError>),
937}
938
939#[derive(Debug, Clone, thiserror::Error)]
941#[error("{field}: {message}")]
942pub struct ValidationError {
943 pub field: String,
945 pub message: String,
947}
948
949fn format_validation_errors(errors: &[ValidationError]) -> String {
950 errors
951 .iter()
952 .map(|e| format!(" - {}", e))
953 .collect::<Vec<_>>()
954 .join("\n")
955}
956
957pub fn default_config_path() -> PathBuf {
959 dirs::config_dir()
960 .unwrap_or_else(|| PathBuf::from("."))
961 .join("aranet")
962 .join("server.toml")
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968
969 #[test]
970 fn test_config_default() {
971 let config = Config::default();
972 assert_eq!(config.server.bind, "127.0.0.1:8080");
973 assert!(config.devices.is_empty());
974 }
975
976 #[test]
977 fn test_server_config_default() {
978 let config = ServerConfig::default();
979 assert_eq!(config.bind, "127.0.0.1:8080");
980 assert_eq!(config.broadcast_buffer, DEFAULT_BROADCAST_BUFFER);
981 }
982
983 #[test]
984 fn test_storage_config_default() {
985 let config = StorageConfig::default();
986 assert_eq!(config.path, aranet_store::default_db_path());
987 }
988
989 #[test]
990 fn test_device_config_serde() {
991 let toml = r#"
992 address = "AA:BB:CC:DD:EE:FF"
993 alias = "Living Room"
994 poll_interval = 120
995 "#;
996 let config: DeviceConfig = toml::from_str(toml).unwrap();
997 assert_eq!(config.address, "AA:BB:CC:DD:EE:FF");
998 assert_eq!(config.alias, Some("Living Room".to_string()));
999 assert_eq!(config.poll_interval, 120);
1000 }
1001
1002 #[test]
1003 fn test_device_config_default_poll_interval() {
1004 let toml = r#"address = "AA:BB:CC:DD:EE:FF""#;
1005 let config: DeviceConfig = toml::from_str(toml).unwrap();
1006 assert_eq!(config.poll_interval, 60);
1007 assert_eq!(config.alias, None);
1008 }
1009
1010 #[test]
1011 fn test_config_save_and_load() {
1012 let temp_dir = tempfile::tempdir().unwrap();
1013 let config_path = temp_dir.path().join("test_config.toml");
1014
1015 let config = Config {
1016 server: ServerConfig {
1017 bind: "0.0.0.0:9090".to_string(),
1018 ..Default::default()
1019 },
1020 storage: StorageConfig {
1021 path: PathBuf::from("/tmp/test.db"),
1022 },
1023 devices: vec![DeviceConfig {
1024 address: "AA:BB:CC:DD:EE:FF".to_string(),
1025 alias: Some("Test Device".to_string()),
1026 poll_interval: 30,
1027 }],
1028 ..Default::default()
1029 };
1030
1031 config.save(&config_path).unwrap();
1032 let loaded = Config::load(&config_path).unwrap();
1033
1034 assert_eq!(loaded.server.bind, "0.0.0.0:9090");
1035 assert_eq!(loaded.storage.path, PathBuf::from("/tmp/test.db"));
1036 assert_eq!(loaded.devices.len(), 1);
1037 assert_eq!(loaded.devices[0].address, "AA:BB:CC:DD:EE:FF");
1038 assert_eq!(loaded.devices[0].alias, Some("Test Device".to_string()));
1039 assert_eq!(loaded.devices[0].poll_interval, 30);
1040 }
1041
1042 #[test]
1043 fn test_config_load_nonexistent() {
1044 let result = Config::load("/nonexistent/path/config.toml");
1045 assert!(matches!(result, Err(ConfigError::Read { .. })));
1046 }
1047
1048 #[test]
1049 fn test_config_load_invalid_toml() {
1050 let temp_dir = tempfile::tempdir().unwrap();
1051 let config_path = temp_dir.path().join("invalid.toml");
1052 std::fs::write(&config_path, "this is not valid { toml").unwrap();
1053
1054 let result = Config::load(&config_path);
1055 assert!(matches!(result, Err(ConfigError::Parse { .. })));
1056 }
1057
1058 #[test]
1059 fn test_config_full_toml() {
1060 let toml = r#"
1061 [server]
1062 bind = "192.168.1.1:8888"
1063
1064 [storage]
1065 path = "/data/aranet.db"
1066
1067 [[devices]]
1068 address = "AA:BB:CC:DD:EE:FF"
1069 alias = "Living Room"
1070 poll_interval = 60
1071
1072 [[devices]]
1073 address = "11:22:33:44:55:66"
1074 poll_interval = 120
1075 "#;
1076
1077 let config: Config = toml::from_str(toml).unwrap();
1078 assert_eq!(config.server.bind, "192.168.1.1:8888");
1079 assert_eq!(config.storage.path, PathBuf::from("/data/aranet.db"));
1080 assert_eq!(config.devices.len(), 2);
1081 assert_eq!(config.devices[0].alias, Some("Living Room".to_string()));
1082 assert_eq!(config.devices[1].alias, None);
1083 }
1084
1085 #[test]
1086 fn test_default_config_path() {
1087 let path = default_config_path();
1088 assert!(path.ends_with("aranet/server.toml"));
1089 }
1090
1091 #[test]
1092 fn test_config_error_display() {
1093 let error = ConfigError::Read {
1094 path: PathBuf::from("/test/path"),
1095 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
1096 };
1097 let display = format!("{}", error);
1098 assert!(display.contains("/test/path"));
1099 assert!(display.contains("not found"));
1100 }
1101
1102 #[test]
1107 fn test_default_config_validates() {
1108 let config = Config::default();
1109 assert!(config.validate().is_ok());
1110 }
1111
1112 #[test]
1113 fn test_server_bind_validation() {
1114 let valid = ServerConfig {
1116 bind: "127.0.0.1:8080".to_string(),
1117 ..Default::default()
1118 };
1119 assert!(valid.validate().is_empty());
1120
1121 let valid_ipv6 = ServerConfig {
1122 bind: "[::1]:8080".to_string(),
1123 ..Default::default()
1124 };
1125 assert!(valid_ipv6.validate().is_empty());
1126
1127 let valid_hostname = ServerConfig {
1128 bind: "localhost:8080".to_string(),
1129 ..Default::default()
1130 };
1131 assert!(valid_hostname.validate().is_empty());
1132
1133 let empty = ServerConfig {
1135 bind: "".to_string(),
1136 ..Default::default()
1137 };
1138 let errors = empty.validate();
1139 assert_eq!(errors.len(), 1);
1140 assert!(errors[0].message.contains("cannot be empty"));
1141
1142 let no_port = ServerConfig {
1144 bind: "127.0.0.1".to_string(),
1145 ..Default::default()
1146 };
1147 let errors = no_port.validate();
1148 assert_eq!(errors.len(), 1);
1149 assert!(errors[0].message.contains("host:port"));
1150
1151 let port_zero = ServerConfig {
1153 bind: "127.0.0.1:0".to_string(),
1154 ..Default::default()
1155 };
1156 let errors = port_zero.validate();
1157 assert_eq!(errors.len(), 1);
1158 assert!(errors[0].message.contains("cannot be 0"));
1159
1160 let bad_port = ServerConfig {
1162 bind: "127.0.0.1:abc".to_string(),
1163 ..Default::default()
1164 };
1165 let errors = bad_port.validate();
1166 assert_eq!(errors.len(), 1);
1167 assert!(errors[0].message.contains("must be a number"));
1168
1169 let zero_buffer = ServerConfig {
1171 broadcast_buffer: 0,
1172 ..Default::default()
1173 };
1174 let errors = zero_buffer.validate();
1175 assert_eq!(errors.len(), 1);
1176 assert!(errors[0].field.contains("broadcast_buffer"));
1177 }
1178
1179 #[test]
1180 fn test_storage_path_validation() {
1181 let valid = StorageConfig {
1183 path: PathBuf::from("/data/aranet.db"),
1184 };
1185 assert!(valid.validate().is_empty());
1186
1187 let empty = StorageConfig {
1189 path: PathBuf::new(),
1190 };
1191 let errors = empty.validate();
1192 assert_eq!(errors.len(), 1);
1193 assert!(errors[0].message.contains("cannot be empty"));
1194 }
1195
1196 #[test]
1197 fn test_device_config_validation() {
1198 let valid = DeviceConfig {
1200 address: "AA:BB:CC:DD:EE:FF".to_string(),
1201 alias: Some("Living Room".to_string()),
1202 poll_interval: 60,
1203 };
1204 assert!(valid.validate("devices[0]").is_empty());
1205
1206 let empty_addr = DeviceConfig {
1208 address: "".to_string(),
1209 alias: None,
1210 poll_interval: 60,
1211 };
1212 let errors = empty_addr.validate("devices[0]");
1213 assert_eq!(errors.len(), 1);
1214 assert!(errors[0].message.contains("cannot be empty"));
1215
1216 let short_addr = DeviceConfig {
1218 address: "AB".to_string(),
1219 alias: None,
1220 poll_interval: 60,
1221 };
1222 let errors = short_addr.validate("devices[0]");
1223 assert_eq!(errors.len(), 1);
1224 assert!(errors[0].message.contains("too short"));
1225
1226 let empty_alias = DeviceConfig {
1228 address: "Aranet4 12345".to_string(),
1229 alias: Some("".to_string()),
1230 poll_interval: 60,
1231 };
1232 let errors = empty_alias.validate("devices[0]");
1233 assert_eq!(errors.len(), 1);
1234 assert!(errors[0].message.contains("cannot be empty string"));
1235
1236 let short_poll = DeviceConfig {
1238 address: "Aranet4 12345".to_string(),
1239 alias: None,
1240 poll_interval: 5,
1241 };
1242 let errors = short_poll.validate("devices[0]");
1243 assert_eq!(errors.len(), 1);
1244 assert!(errors[0].message.contains("too short"));
1245
1246 let long_poll = DeviceConfig {
1248 address: "Aranet4 12345".to_string(),
1249 alias: None,
1250 poll_interval: 7200,
1251 };
1252 let errors = long_poll.validate("devices[0]");
1253 assert_eq!(errors.len(), 1);
1254 assert!(errors[0].message.contains("too long"));
1255 }
1256
1257 #[test]
1258 fn test_duplicate_device_addresses() {
1259 let config = Config {
1260 server: ServerConfig::default(),
1261 storage: StorageConfig::default(),
1262 devices: vec![
1263 DeviceConfig {
1264 address: "Aranet4 12345".to_string(),
1265 alias: Some("Office".to_string()),
1266 poll_interval: 60,
1267 },
1268 DeviceConfig {
1269 address: "Aranet4 12345".to_string(), alias: Some("Bedroom".to_string()),
1271 poll_interval: 60,
1272 },
1273 ],
1274 ..Default::default()
1275 };
1276
1277 let result = config.validate();
1278 assert!(result.is_err());
1279 if let Err(ConfigError::Validation(errors)) = result {
1280 assert!(errors.iter().any(|e| e.message.contains("duplicate")));
1281 }
1282 }
1283
1284 #[test]
1285 fn test_duplicate_addresses_case_insensitive() {
1286 let config = Config {
1287 server: ServerConfig::default(),
1288 storage: StorageConfig::default(),
1289 devices: vec![
1290 DeviceConfig {
1291 address: "Aranet4 12345".to_string(),
1292 alias: None,
1293 poll_interval: 60,
1294 },
1295 DeviceConfig {
1296 address: "ARANET4 12345".to_string(), alias: None,
1298 poll_interval: 60,
1299 },
1300 ],
1301 ..Default::default()
1302 };
1303
1304 let result = config.validate();
1305 assert!(result.is_err());
1306 }
1307
1308 #[test]
1309 fn test_validation_error_display() {
1310 let error = ValidationError {
1311 field: "server.bind".to_string(),
1312 message: "invalid port".to_string(),
1313 };
1314 assert_eq!(format!("{}", error), "server.bind: invalid port");
1315 }
1316
1317 #[test]
1318 fn test_config_validation_error_display() {
1319 let errors = vec![
1320 ValidationError {
1321 field: "server.bind".to_string(),
1322 message: "port cannot be 0".to_string(),
1323 },
1324 ValidationError {
1325 field: "devices[0].address".to_string(),
1326 message: "cannot be empty".to_string(),
1327 },
1328 ];
1329 let error = ConfigError::Validation(errors);
1330 let display = format!("{}", error);
1331 assert!(display.contains("server.bind"));
1332 assert!(display.contains("devices[0].address"));
1333 }
1334
1335 #[test]
1340 fn test_prometheus_config_default() {
1341 let config = PrometheusConfig::default();
1342 assert!(!config.enabled);
1343 assert!(config.push_gateway.is_none());
1344 assert_eq!(config.push_interval, 60);
1345 }
1346
1347 #[test]
1348 fn test_prometheus_config_validates() {
1349 let config = PrometheusConfig::default();
1350 assert!(config.validate().is_empty());
1351 }
1352
1353 #[test]
1354 fn test_prometheus_config_empty_push_gateway() {
1355 let config = PrometheusConfig {
1356 enabled: true,
1357 push_gateway: Some("".to_string()),
1358 push_interval: 60,
1359 };
1360 let errors = config.validate();
1361 assert_eq!(errors.len(), 1);
1362 assert!(errors[0].message.contains("cannot be empty"));
1363 }
1364
1365 #[test]
1366 fn test_prometheus_config_short_push_interval() {
1367 let config = PrometheusConfig {
1368 enabled: true,
1369 push_gateway: None,
1370 push_interval: 5,
1371 };
1372 let errors = config.validate();
1373 assert_eq!(errors.len(), 1);
1374 assert!(errors[0].message.contains("too short"));
1375 }
1376
1377 #[test]
1378 fn test_prometheus_config_serde() {
1379 let toml = r#"
1380 enabled = true
1381 push_gateway = "http://localhost:9091"
1382 push_interval = 30
1383 "#;
1384 let config: PrometheusConfig = toml::from_str(toml).unwrap();
1385 assert!(config.enabled);
1386 assert_eq!(
1387 config.push_gateway,
1388 Some("http://localhost:9091".to_string())
1389 );
1390 assert_eq!(config.push_interval, 30);
1391 }
1392
1393 #[test]
1398 fn test_mqtt_config_default() {
1399 let config = MqttConfig::default();
1400 assert!(!config.enabled);
1401 assert_eq!(config.broker, "mqtt://localhost:1883");
1402 assert_eq!(config.topic_prefix, "aranet");
1403 assert_eq!(config.client_id, "aranet-service");
1404 assert_eq!(config.qos, 1);
1405 assert!(!config.retain);
1406 assert!(config.username.is_none());
1407 assert!(config.password.is_none());
1408 assert_eq!(config.keep_alive, 60);
1409 }
1410
1411 #[test]
1412 fn test_mqtt_config_validates_when_disabled() {
1413 let config = MqttConfig::default();
1414 assert!(config.validate().is_empty());
1415 }
1416
1417 #[test]
1418 fn test_mqtt_config_validates_when_enabled() {
1419 let config = MqttConfig {
1420 enabled: true,
1421 ..Default::default()
1422 };
1423 assert!(config.validate().is_empty());
1424 }
1425
1426 #[test]
1427 fn test_mqtt_config_empty_broker() {
1428 let config = MqttConfig {
1429 enabled: true,
1430 broker: "".to_string(),
1431 ..Default::default()
1432 };
1433 let errors = config.validate();
1434 assert!(
1435 errors
1436 .iter()
1437 .any(|e| e.message.contains("broker URL cannot be empty"))
1438 );
1439 }
1440
1441 #[test]
1442 fn test_mqtt_config_invalid_broker_scheme() {
1443 let config = MqttConfig {
1444 enabled: true,
1445 broker: "http://localhost:1883".to_string(),
1446 ..Default::default()
1447 };
1448 let errors = config.validate();
1449 assert!(errors.iter().any(|e| e.message.contains("mqtt://")));
1450 }
1451
1452 #[test]
1453 fn test_mqtt_config_empty_topic_prefix() {
1454 let config = MqttConfig {
1455 enabled: true,
1456 topic_prefix: "".to_string(),
1457 ..Default::default()
1458 };
1459 let errors = config.validate();
1460 assert!(
1461 errors
1462 .iter()
1463 .any(|e| e.message.contains("topic prefix cannot be empty"))
1464 );
1465 }
1466
1467 #[test]
1468 fn test_mqtt_config_empty_client_id() {
1469 let config = MqttConfig {
1470 enabled: true,
1471 client_id: "".to_string(),
1472 ..Default::default()
1473 };
1474 let errors = config.validate();
1475 assert!(
1476 errors
1477 .iter()
1478 .any(|e| e.message.contains("client ID cannot be empty"))
1479 );
1480 }
1481
1482 #[test]
1483 fn test_mqtt_config_invalid_qos() {
1484 let config = MqttConfig {
1485 enabled: true,
1486 qos: 5,
1487 ..Default::default()
1488 };
1489 let errors = config.validate();
1490 assert!(errors.iter().any(|e| e.message.contains("invalid QoS")));
1491 }
1492
1493 #[test]
1494 fn test_mqtt_config_short_keep_alive() {
1495 let config = MqttConfig {
1496 enabled: true,
1497 keep_alive: 2,
1498 ..Default::default()
1499 };
1500 let errors = config.validate();
1501 assert!(
1502 errors
1503 .iter()
1504 .any(|e| e.message.contains("keep-alive interval"))
1505 );
1506 }
1507
1508 #[test]
1509 fn test_mqtt_config_serde() {
1510 let toml = r#"
1511 enabled = true
1512 broker = "mqtts://broker.example.com:8883"
1513 topic_prefix = "home/sensors"
1514 client_id = "my-service"
1515 qos = 2
1516 retain = true
1517 username = "user"
1518 password = "secret"
1519 keep_alive = 30
1520 "#;
1521 let config: MqttConfig = toml::from_str(toml).unwrap();
1522 assert!(config.enabled);
1523 assert_eq!(config.broker, "mqtts://broker.example.com:8883");
1524 assert_eq!(config.topic_prefix, "home/sensors");
1525 assert_eq!(config.client_id, "my-service");
1526 assert_eq!(config.qos, 2);
1527 assert!(config.retain);
1528 assert_eq!(config.username, Some("user".to_string()));
1529 assert_eq!(config.password, Some("secret".to_string()));
1530 assert_eq!(config.keep_alive, 30);
1531 }
1532
1533 #[test]
1534 fn test_influxdb_config_requires_org_when_enabled() {
1535 let config = InfluxDbConfig {
1536 enabled: true,
1537 org: " ".to_string(),
1538 ..Default::default()
1539 };
1540 let errors = config.validate();
1541 assert!(errors.iter().any(
1542 |e| e.field == "influxdb.org" && e.message.contains("organization cannot be empty")
1543 ));
1544 }
1545
1546 #[test]
1547 fn test_influxdb_config_validates_when_enabled_with_org() {
1548 let config = InfluxDbConfig {
1549 enabled: true,
1550 org: "aranet".to_string(),
1551 ..Default::default()
1552 };
1553 assert!(config.validate().is_empty());
1554 }
1555
1556 #[test]
1557 fn test_config_with_prometheus_and_mqtt() {
1558 let toml = r#"
1559 [server]
1560 bind = "127.0.0.1:8080"
1561
1562 [prometheus]
1563 enabled = true
1564
1565 [mqtt]
1566 enabled = true
1567 broker = "mqtt://localhost:1883"
1568 topic_prefix = "aranet"
1569 "#;
1570 let config: Config = toml::from_str(toml).unwrap();
1571 assert!(config.prometheus.enabled);
1572 assert!(config.mqtt.enabled);
1573 assert!(config.validate().is_ok());
1574 }
1575}