Skip to main content

aranet_service/
config.rs

1//! Server configuration.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7/// Server configuration.
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9#[serde(default)]
10pub struct Config {
11    /// Server settings.
12    pub server: ServerConfig,
13    /// Storage settings.
14    pub storage: StorageConfig,
15    /// Security settings.
16    #[serde(default)]
17    pub security: SecurityConfig,
18    /// Devices to monitor.
19    #[serde(default)]
20    pub devices: Vec<DeviceConfig>,
21    /// Prometheus metrics settings.
22    #[serde(default)]
23    pub prometheus: PrometheusConfig,
24    /// MQTT publisher settings.
25    #[serde(default)]
26    pub mqtt: MqttConfig,
27}
28
29impl Config {
30    /// Load configuration from the default path.
31    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    /// Load configuration from a file.
41    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    /// Save configuration to a file.
53    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        // Create parent directories if needed
57        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    /// Validate the configuration and return any errors.
71    ///
72    /// This checks:
73    /// - Server bind address is valid (host:port format)
74    /// - Storage path is not empty
75    /// - Device addresses are not empty
76    /// - Device poll intervals are within reasonable bounds (10s - 1 hour)
77    /// - No duplicate device addresses
78    ///
79    /// # Example
80    ///
81    /// ```
82    /// use aranet_service::Config;
83    ///
84    /// let config = Config::default();
85    /// config.validate().expect("Default config should be valid");
86    /// ```
87    pub fn validate(&self) -> Result<(), ConfigError> {
88        let mut errors = Vec::new();
89
90        // Validate server config
91        errors.extend(self.server.validate());
92
93        // Validate storage config
94        errors.extend(self.storage.validate());
95
96        // Validate security config
97        errors.extend(self.security.validate());
98
99        // Validate devices
100        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            // Check for duplicate addresses
106            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        // Validate Prometheus config
116        errors.extend(self.prometheus.validate());
117
118        // Validate MQTT config
119        errors.extend(self.mqtt.validate());
120
121        if errors.is_empty() {
122            Ok(())
123        } else {
124            Err(ConfigError::Validation(errors))
125        }
126    }
127
128    /// Load and validate configuration from a file.
129    ///
130    /// This is a convenience method that combines `load()` and `validate()`.
131    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/// Server configuration.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141pub struct ServerConfig {
142    /// Bind address (e.g., "127.0.0.1:8080").
143    pub bind: String,
144    /// Broadcast channel buffer size for real-time reading updates.
145    ///
146    /// This determines how many messages can be buffered before slow
147    /// subscribers start missing messages. A larger buffer uses more memory
148    /// but is more tolerant of slow WebSocket clients.
149    ///
150    /// Default: 100
151    #[serde(default = "default_broadcast_buffer")]
152    pub broadcast_buffer: usize,
153}
154
155/// Default broadcast buffer size.
156pub 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    /// Validate server configuration.
173    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            // Check for valid host:port format
183            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                // Validate port
194                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(_) => {} // Valid port
212                }
213            }
214        }
215
216        errors
217    }
218}
219
220/// Storage configuration.
221#[derive(Debug, Clone, Serialize, Deserialize)]
222#[serde(default)]
223pub struct StorageConfig {
224    /// Database file path.
225    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    /// Validate storage configuration.
238    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/// Security configuration for API protection.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(default)]
255pub struct SecurityConfig {
256    /// Enable API key authentication.
257    /// When enabled, clients must provide the API key in the `X-API-Key` header.
258    pub api_key_enabled: bool,
259    /// The API key required for authentication (if enabled).
260    /// Should be a secure random string of at least 32 characters.
261    pub api_key: Option<String>,
262    /// Enable rate limiting.
263    pub rate_limit_enabled: bool,
264    /// Maximum requests per window.
265    #[serde(default = "default_rate_limit_requests")]
266    pub rate_limit_requests: u32,
267    /// Rate limit window in seconds.
268    #[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 limiting enabled by default to prevent DoS attacks
286            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    /// Validate security configuration.
295    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/// Prometheus metrics configuration.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(default)]
338pub struct PrometheusConfig {
339    /// Whether Prometheus metrics endpoint is enabled.
340    pub enabled: bool,
341    /// Optional push gateway URL for pushing metrics.
342    /// If not set, metrics are only available via the /metrics endpoint.
343    pub push_gateway: Option<String>,
344    /// Push interval in seconds (only used with push_gateway).
345    #[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    /// Validate Prometheus configuration.
365    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/// MQTT publisher configuration.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(default)]
394pub struct MqttConfig {
395    /// Whether MQTT publishing is enabled.
396    pub enabled: bool,
397    /// MQTT broker URL (e.g., "mqtt://localhost:1883" or "mqtts://broker.example.com:8883").
398    pub broker: String,
399    /// Topic prefix for published messages (e.g., "aranet" -> "aranet/{device}/co2").
400    #[serde(default = "default_topic_prefix")]
401    pub topic_prefix: String,
402    /// MQTT client ID.
403    #[serde(default = "default_client_id")]
404    pub client_id: String,
405    /// Quality of Service level (0 = AtMostOnce, 1 = AtLeastOnce, 2 = ExactlyOnce).
406    #[serde(default = "default_qos")]
407    pub qos: u8,
408    /// Whether to retain messages on the broker.
409    #[serde(default)]
410    pub retain: bool,
411    /// Optional username for authentication.
412    pub username: Option<String>,
413    /// Optional password for authentication.
414    pub password: Option<String>,
415    /// Keep-alive interval in seconds.
416    #[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    /// Validate MQTT configuration.
454    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/// Configuration for a device to monitor.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct DeviceConfig {
512    /// Device address or name.
513    pub address: String,
514    /// Friendly alias for the device.
515    #[serde(default)]
516    pub alias: Option<String>,
517    /// Poll interval in seconds.
518    #[serde(default = "default_poll_interval")]
519    pub poll_interval: u64,
520}
521
522/// Minimum poll interval in seconds (10 seconds).
523pub const MIN_POLL_INTERVAL: u64 = 10;
524/// Maximum poll interval in seconds (1 hour).
525pub const MAX_POLL_INTERVAL: u64 = 3600;
526
527fn default_poll_interval() -> u64 {
528    60
529}
530
531impl DeviceConfig {
532    /// Validate device configuration.
533    pub fn validate(&self, prefix: &str) -> Vec<ValidationError> {
534        let mut errors = Vec::new();
535
536        // Address validation
537        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        // Alias validation (if provided)
553        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        // Poll interval validation
563        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/// Configuration errors.
586#[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/// A single validation error with context.
610#[derive(Debug, Clone)]
611pub struct ValidationError {
612    /// The field path (e.g., `server.bind` or `devices[0].address`).
613    pub field: String,
614    /// Description of the validation failure.
615    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
632/// Default configuration file path.
633pub 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    // ==========================================================================
777    // Validation tests
778    // ==========================================================================
779
780    #[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        // Valid bind addresses
789        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        // Invalid: empty
808        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        // Invalid: no port
817        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        // Invalid: port 0
826        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        // Invalid: non-numeric port
835        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        // Valid path
847        let valid = StorageConfig {
848            path: PathBuf::from("/data/aranet.db"),
849        };
850        assert!(valid.validate().is_empty());
851
852        // Invalid: empty path
853        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        // Valid device
864        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        // Invalid: empty address
872        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        // Invalid: address too short
882        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        // Invalid: empty alias (should be null instead)
892        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        // Invalid: poll interval too short
902        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        // Invalid: poll interval too long
912        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(), // Duplicate
935                    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(), // Same, different case
962                    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    // ==========================================================================
1001    // Prometheus config tests
1002    // ==========================================================================
1003
1004    #[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    // ==========================================================================
1059    // MQTT config tests
1060    // ==========================================================================
1061
1062    #[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}