Skip to main content

aranet_service/
config.rs

1//! Server configuration.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7/// Push a validation error onto `$errors` with the given field and message.
8macro_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/// Server configuration.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25#[serde(default)]
26pub struct Config {
27    /// Server settings.
28    pub server: ServerConfig,
29    /// Storage settings.
30    pub storage: StorageConfig,
31    /// Security settings.
32    #[serde(default)]
33    pub security: SecurityConfig,
34    /// Devices to monitor.
35    #[serde(default)]
36    pub devices: Vec<DeviceConfig>,
37    /// Prometheus metrics settings.
38    #[serde(default)]
39    pub prometheus: PrometheusConfig,
40    /// MQTT publisher settings.
41    #[serde(default)]
42    pub mqtt: MqttConfig,
43    /// Desktop notification settings.
44    #[serde(default)]
45    pub notifications: NotificationConfig,
46    /// Webhook notification settings.
47    #[serde(default)]
48    pub webhooks: WebhookConfig,
49    /// InfluxDB export settings.
50    #[serde(default)]
51    pub influxdb: InfluxDbConfig,
52}
53
54impl Config {
55    /// Load configuration from the default path.
56    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    /// Load configuration from a file.
66    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    /// Save configuration to a file.
78    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        // Create parent directories if needed
82        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        // Restrict permissions to owner-only since config may contain API keys
95        #[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    /// Validate the configuration and return any errors.
106    ///
107    /// This checks:
108    /// - Server bind address is valid (host:port format)
109    /// - Storage path is not empty
110    /// - Device addresses are not empty
111    /// - Device poll intervals are within reasonable bounds (10s - 1 hour)
112    /// - No duplicate device addresses
113    ///
114    /// # Example
115    ///
116    /// ```
117    /// use aranet_service::Config;
118    ///
119    /// let config = Config::default();
120    /// config.validate().expect("Default config should be valid");
121    /// ```
122    pub fn validate(&self) -> Result<(), ConfigError> {
123        let mut errors = Vec::new();
124
125        // Validate server config
126        errors.extend(self.server.validate());
127
128        // Validate storage config
129        errors.extend(self.storage.validate());
130
131        // Validate security config
132        errors.extend(self.security.validate());
133
134        // Validate devices
135        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            // Check for duplicate addresses
141            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        // Validate Prometheus config
153        errors.extend(self.prometheus.validate());
154
155        // Validate MQTT config
156        errors.extend(self.mqtt.validate());
157
158        // Validate webhook config
159        errors.extend(self.webhooks.validate());
160
161        // Validate InfluxDB config
162        errors.extend(self.influxdb.validate());
163
164        if errors.is_empty() {
165            Ok(())
166        } else {
167            Err(ConfigError::Validation(errors))
168        }
169    }
170
171    /// Load and validate configuration from a file.
172    ///
173    /// This is a convenience method that combines `load()` and `validate()`.
174    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/// Server configuration.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(default)]
184pub struct ServerConfig {
185    /// Bind address (e.g., "127.0.0.1:8080").
186    pub bind: String,
187    /// Broadcast channel buffer size for real-time reading updates.
188    ///
189    /// This determines how many messages can be buffered before slow
190    /// subscribers start missing messages. A larger buffer uses more memory
191    /// but is more tolerant of slow WebSocket clients.
192    ///
193    /// Default: 100
194    #[serde(default = "default_broadcast_buffer")]
195    pub broadcast_buffer: usize,
196}
197
198/// Default broadcast buffer size.
199pub 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    /// Validate server configuration.
216    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            // Check for valid host:port format
223            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                // Validate port
233                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(_) => {} // Valid port
247                }
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/// Storage configuration.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(default)]
273pub struct StorageConfig {
274    /// Database file path.
275    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    /// Validate storage configuration.
288    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/// Security configuration for API protection.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301#[serde(default)]
302pub struct SecurityConfig {
303    /// Enable API key authentication.
304    /// When enabled, clients must provide the API key in the `X-API-Key` header.
305    pub api_key_enabled: bool,
306    /// The API key required for authentication (if enabled).
307    /// Should be a secure random string of at least 32 characters.
308    pub api_key: Option<String>,
309    /// Enable rate limiting.
310    pub rate_limit_enabled: bool,
311    /// Maximum requests per window.
312    #[serde(default = "default_rate_limit_requests")]
313    pub rate_limit_requests: u32,
314    /// Rate limit window in seconds.
315    #[serde(default = "default_rate_limit_window")]
316    pub rate_limit_window_secs: u64,
317    /// Maximum number of tracked IPs for rate limiting.
318    ///
319    /// When the number of tracked IPs exceeds this limit, the oldest entries
320    /// are evicted to prevent unbounded memory growth from many unique IPs.
321    #[serde(default = "default_rate_limit_max_entries")]
322    pub rate_limit_max_entries: usize,
323    /// Allowed CORS origins.
324    ///
325    /// By default, only localhost origins are allowed. Set to `["*"]` to allow
326    /// all origins (not recommended for production).
327    ///
328    /// Examples: `["http://localhost:3000", "http://127.0.0.1:8080"]`
329    #[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 limiting enabled by default to prevent DoS attacks
358            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    /// Validate security configuration.
369    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/// Prometheus metrics configuration.
414#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(default)]
416pub struct PrometheusConfig {
417    /// Whether Prometheus metrics endpoint is enabled.
418    pub enabled: bool,
419    /// Optional push gateway URL for pushing metrics.
420    /// If not set, metrics are only available via the /metrics endpoint.
421    pub push_gateway: Option<String>,
422    /// Push interval in seconds (only used with push_gateway).
423    #[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    /// Validate Prometheus configuration.
443    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/// MQTT publisher configuration.
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(default)]
472pub struct MqttConfig {
473    /// Whether MQTT publishing is enabled.
474    pub enabled: bool,
475    /// MQTT broker URL (e.g., "mqtt://localhost:1883" or "mqtts://broker.example.com:8883").
476    pub broker: String,
477    /// Topic prefix for published messages (e.g., "aranet" -> "aranet/{device}/co2").
478    #[serde(default = "default_topic_prefix")]
479    pub topic_prefix: String,
480    /// MQTT client ID.
481    #[serde(default = "default_client_id")]
482    pub client_id: String,
483    /// Quality of Service level (0 = AtMostOnce, 1 = AtLeastOnce, 2 = ExactlyOnce).
484    #[serde(default = "default_qos")]
485    pub qos: u8,
486    /// Whether to retain messages on the broker.
487    #[serde(default)]
488    pub retain: bool,
489    /// Optional username for authentication.
490    pub username: Option<String>,
491    /// Optional password for authentication.
492    pub password: Option<String>,
493    /// Keep-alive interval in seconds.
494    #[serde(default = "default_keep_alive")]
495    pub keep_alive: u64,
496    /// Enable Home Assistant MQTT auto-discovery.
497    /// When enabled, discovery messages are published to the HA discovery topic
498    /// so devices appear automatically in Home Assistant.
499    #[serde(default)]
500    pub homeassistant: bool,
501    /// Home Assistant discovery topic prefix.
502    #[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    /// Validate MQTT configuration.
546    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/// Configuration for a device to monitor.
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct DeviceConfig {
599    /// Device address or name.
600    pub address: String,
601    /// Friendly alias for the device.
602    #[serde(default)]
603    pub alias: Option<String>,
604    /// Poll interval in seconds.
605    #[serde(default = "default_poll_interval")]
606    pub poll_interval: u64,
607}
608
609/// Minimum poll interval in seconds (10 seconds).
610pub const MIN_POLL_INTERVAL: u64 = 10;
611/// Maximum poll interval in seconds (1 hour).
612pub const MAX_POLL_INTERVAL: u64 = 3600;
613
614fn default_poll_interval() -> u64 {
615    60
616}
617
618impl DeviceConfig {
619    /// Validate device configuration.
620    pub fn validate(&self, prefix: &str) -> Vec<ValidationError> {
621        let mut errors = Vec::new();
622
623        // Address validation
624        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        // Alias validation (if provided)
640        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        // Poll interval validation
651        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/// Desktop notification settings.
674#[derive(Debug, Clone, Serialize, Deserialize)]
675#[serde(default)]
676pub struct NotificationConfig {
677    /// Whether desktop notifications are enabled.
678    pub enabled: bool,
679    /// CO2 threshold in ppm (notify when exceeded).
680    #[serde(default = "default_co2_threshold")]
681    pub co2_threshold: u16,
682    /// Radon threshold in Bq/m³ (notify when exceeded).
683    #[serde(default = "default_radon_threshold")]
684    pub radon_threshold: u32,
685    /// Minimum interval between notifications per device (in seconds).
686    #[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/// Webhook notification configuration.
714#[derive(Debug, Clone, Serialize, Deserialize)]
715#[serde(default)]
716pub struct WebhookConfig {
717    /// Whether webhook notifications are enabled.
718    pub enabled: bool,
719    /// CO2 threshold in ppm (triggers "co2_high" event).
720    #[serde(default = "default_co2_threshold")]
721    pub co2_threshold: u16,
722    /// Radon threshold in Bq/m³ (triggers "radon_high" event).
723    #[serde(default = "default_radon_threshold")]
724    pub radon_threshold: u32,
725    /// Battery threshold in % (triggers "battery_low" event when at or below).
726    #[serde(default = "default_battery_threshold")]
727    pub battery_threshold: u8,
728    /// Minimum interval between alerts per device per event type (in seconds).
729    #[serde(default = "default_webhook_cooldown")]
730    pub cooldown_secs: u64,
731    /// Webhook endpoints to notify.
732    #[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    /// Validate webhook configuration.
759    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/// A webhook endpoint configuration.
815#[derive(Debug, Clone, Serialize, Deserialize)]
816pub struct WebhookEndpoint {
817    /// The URL to POST alerts to.
818    pub url: String,
819    /// Event types to send to this endpoint.
820    /// Valid values: "co2_high", "radon_high", "battery_low"
821    pub events: Vec<String>,
822    /// Optional HTTP headers to include in requests (e.g., authorization tokens).
823    #[serde(default)]
824    pub headers: std::collections::HashMap<String, String>,
825}
826
827/// InfluxDB export configuration.
828#[derive(Debug, Clone, Serialize, Deserialize)]
829#[serde(default)]
830pub struct InfluxDbConfig {
831    /// Whether InfluxDB export is enabled.
832    pub enabled: bool,
833    /// InfluxDB server URL (e.g., "http://localhost:8086").
834    pub url: String,
835    /// InfluxDB API token for authentication.
836    pub token: Option<String>,
837    /// Organization name (InfluxDB 2.x).
838    pub org: String,
839    /// Bucket name (InfluxDB 2.x) or database name (1.x).
840    pub bucket: String,
841    /// Measurement name for sensor readings.
842    #[serde(default = "default_influxdb_measurement")]
843    pub measurement: String,
844    /// Write precision.
845    #[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    /// Validate InfluxDB configuration.
873    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/// Configuration errors.
916#[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/// A single validation error with context.
940#[derive(Debug, Clone, thiserror::Error)]
941#[error("{field}: {message}")]
942pub struct ValidationError {
943    /// The field path (e.g., `server.bind` or `devices[0].address`).
944    pub field: String,
945    /// Description of the validation failure.
946    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
957/// Default configuration file path.
958pub 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    // ==========================================================================
1103    // Validation tests
1104    // ==========================================================================
1105
1106    #[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        // Valid bind addresses
1115        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        // Invalid: empty
1134        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        // Invalid: no port
1143        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        // Invalid: port 0
1152        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        // Invalid: non-numeric port
1161        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        // Invalid: zero broadcast buffer
1170        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        // Valid path
1182        let valid = StorageConfig {
1183            path: PathBuf::from("/data/aranet.db"),
1184        };
1185        assert!(valid.validate().is_empty());
1186
1187        // Invalid: empty path
1188        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        // Valid device
1199        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        // Invalid: empty address
1207        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        // Invalid: address too short
1217        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        // Invalid: empty alias (should be null instead)
1227        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        // Invalid: poll interval too short
1237        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        // Invalid: poll interval too long
1247        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(), // Duplicate
1270                    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(), // Same, different case
1297                    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    // ==========================================================================
1336    // Prometheus config tests
1337    // ==========================================================================
1338
1339    #[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    // ==========================================================================
1394    // MQTT config tests
1395    // ==========================================================================
1396
1397    #[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}