Skip to main content

aranet_cli/
config.rs

1//! Configuration file management.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10/// Configuration file structure
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Config {
13    /// Default device address
14    #[serde(default)]
15    pub device: Option<String>,
16
17    /// Default output format
18    #[serde(default)]
19    pub format: Option<String>,
20
21    /// Disable colored output
22    #[serde(default)]
23    pub no_color: bool,
24
25    /// Use Fahrenheit for temperature
26    #[serde(default)]
27    pub fahrenheit: bool,
28
29    /// Use inHg for pressure (instead of hPa)
30    #[serde(default)]
31    pub inhg: bool,
32
33    /// Use Bq/m³ for radon (instead of pCi/L)
34    #[serde(default)]
35    pub bq: bool,
36
37    /// Connection timeout in seconds
38    #[serde(default)]
39    pub timeout: Option<u64>,
40
41    /// Device aliases (friendly name -> device address)
42    #[serde(default)]
43    pub aliases: HashMap<String, String>,
44
45    /// Last successfully connected device (auto-updated)
46    #[serde(default)]
47    pub last_device: Option<String>,
48
49    /// Name of the last connected device (for display)
50    #[serde(default)]
51    pub last_device_name: Option<String>,
52
53    /// Behavior settings for unified data architecture
54    #[serde(default)]
55    pub behavior: BehaviorConfig,
56
57    /// GUI-specific settings
58    #[serde(default)]
59    pub gui: GuiConfig,
60}
61
62/// GUI-specific configuration settings.
63///
64/// Controls appearance and behavior of the native GUI application.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct GuiConfig {
67    /// Theme preference: "dark", "light", or "system"
68    #[serde(default = "default_theme")]
69    pub theme: String,
70
71    /// Show colored tray icon for elevated CO2 levels.
72    /// When false, always uses native template icon (auto dark/light).
73    /// When true, shows colored icons (yellow/orange/red) for elevated CO2.
74    #[serde(default = "default_true")]
75    pub colored_tray_icon: bool,
76
77    /// Enable desktop notifications for CO2 threshold alerts.
78    #[serde(default = "default_true")]
79    pub notifications_enabled: bool,
80
81    /// Play sound with desktop notifications.
82    #[serde(default = "default_true")]
83    pub notification_sound: bool,
84
85    /// Start the application minimized to system tray.
86    #[serde(default)]
87    pub start_minimized: bool,
88
89    /// Minimize to tray instead of quitting when closing window.
90    #[serde(default = "default_true")]
91    pub close_to_tray: bool,
92
93    /// Temperature unit preference: "celsius" or "fahrenheit".
94    /// Used when device settings are not available.
95    #[serde(default = "default_celsius")]
96    pub temperature_unit: String,
97
98    /// Pressure unit preference: "hpa" or "inhg".
99    /// Used for pressure display.
100    #[serde(default = "default_hpa")]
101    pub pressure_unit: String,
102
103    /// Whether the sidebar is collapsed.
104    #[serde(default)]
105    pub sidebar_collapsed: bool,
106
107    /// Enable compact mode for denser layout on smaller screens.
108    #[serde(default)]
109    pub compact_mode: bool,
110
111    /// Remembered window width.
112    #[serde(default)]
113    pub window_width: Option<f32>,
114
115    /// Remembered window height.
116    #[serde(default)]
117    pub window_height: Option<f32>,
118
119    /// Remembered window X position.
120    #[serde(default)]
121    pub window_x: Option<f32>,
122
123    /// Remembered window Y position.
124    #[serde(default)]
125    pub window_y: Option<f32>,
126
127    /// CO2 warning threshold in ppm (yellow/amber indicator).
128    #[serde(default = "default_co2_warning")]
129    pub co2_warning_threshold: u16,
130
131    /// CO2 danger threshold in ppm (red indicator).
132    #[serde(default = "default_co2_danger")]
133    pub co2_danger_threshold: u16,
134
135    /// Radon warning threshold in Bq/m³.
136    #[serde(default = "default_radon_warning")]
137    pub radon_warning_threshold: u32,
138
139    /// Radon danger threshold in Bq/m³.
140    #[serde(default = "default_radon_danger")]
141    pub radon_danger_threshold: u32,
142
143    /// Default export format: "csv" or "json".
144    #[serde(default = "default_export_format")]
145    pub default_export_format: String,
146
147    /// Custom export directory path. Empty string means use default (Downloads).
148    #[serde(default)]
149    pub export_directory: String,
150
151    /// URL for the aranet-service REST API.
152    /// Default: "http://localhost:8080"
153    #[serde(default = "default_service_url")]
154    pub service_url: String,
155
156    /// Optional API key for authenticated aranet-service deployments.
157    #[serde(default)]
158    pub service_api_key: Option<String>,
159
160    /// Show CO2 readings in dashboard.
161    #[serde(default = "default_true")]
162    pub show_co2: bool,
163
164    /// Show temperature readings in dashboard.
165    #[serde(default = "default_true")]
166    pub show_temperature: bool,
167
168    /// Show humidity readings in dashboard.
169    #[serde(default = "default_true")]
170    pub show_humidity: bool,
171
172    /// Show pressure readings in dashboard.
173    #[serde(default = "default_true")]
174    pub show_pressure: bool,
175
176    /// Do Not Disturb mode - suppress all notifications.
177    #[serde(default)]
178    pub do_not_disturb: bool,
179}
180
181fn default_service_url() -> String {
182    "http://localhost:8080".to_string()
183}
184
185fn default_theme() -> String {
186    "dark".to_string()
187}
188
189fn default_celsius() -> String {
190    "celsius".to_string()
191}
192
193fn default_hpa() -> String {
194    "hpa".to_string()
195}
196
197fn default_co2_warning() -> u16 {
198    1000
199}
200
201fn default_co2_danger() -> u16 {
202    1400
203}
204
205fn default_radon_warning() -> u32 {
206    100
207}
208
209fn default_radon_danger() -> u32 {
210    150
211}
212
213fn default_export_format() -> String {
214    "csv".to_string()
215}
216
217impl Default for GuiConfig {
218    fn default() -> Self {
219        Self {
220            theme: default_theme(),
221            colored_tray_icon: true,
222            notifications_enabled: true,
223            notification_sound: true,
224            start_minimized: false,
225            close_to_tray: true,
226            temperature_unit: default_celsius(),
227            pressure_unit: default_hpa(),
228            sidebar_collapsed: false,
229            compact_mode: false,
230            window_width: None,
231            window_height: None,
232            window_x: None,
233            window_y: None,
234            co2_warning_threshold: default_co2_warning(),
235            co2_danger_threshold: default_co2_danger(),
236            radon_warning_threshold: default_radon_warning(),
237            radon_danger_threshold: default_radon_danger(),
238            default_export_format: default_export_format(),
239            export_directory: String::new(),
240            service_url: default_service_url(),
241            service_api_key: None,
242            show_co2: true,
243            show_temperature: true,
244            show_humidity: true,
245            show_pressure: true,
246            do_not_disturb: false,
247        }
248    }
249}
250
251/// Behavior configuration for unified data architecture.
252///
253/// Controls automatic connection, sync, and device memory across all tools.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct BehaviorConfig {
256    /// Auto-connect to known devices on startup (TUI/GUI)
257    #[serde(default = "default_true")]
258    pub auto_connect: bool,
259
260    /// Auto-sync history on connection
261    #[serde(default = "default_true")]
262    pub auto_sync: bool,
263
264    /// Remember devices in database after connection
265    #[serde(default = "default_true")]
266    pub remember_devices: bool,
267
268    /// Load cached data (devices, readings) on startup
269    #[serde(default = "default_true")]
270    pub load_cache: bool,
271}
272
273fn default_true() -> bool {
274    true
275}
276
277impl Default for BehaviorConfig {
278    fn default() -> Self {
279        Self {
280            auto_connect: true,
281            auto_sync: true,
282            remember_devices: true,
283            load_cache: true,
284        }
285    }
286}
287
288impl Config {
289    /// Get the config file path.
290    ///
291    /// Checks `ARANET_CONFIG_DIR` first, then falls back to the platform config directory.
292    pub fn path() -> PathBuf {
293        std::env::var_os("ARANET_CONFIG_DIR")
294            .map(PathBuf::from)
295            .or_else(|| dirs::config_dir().map(|d| d.join("aranet")))
296            .unwrap_or_else(|| PathBuf::from("."))
297            .join("config.toml")
298    }
299
300    /// Load config from the default path.
301    ///
302    /// Returns an error if the file exists but cannot be read or parsed.
303    pub fn load() -> Result<Self> {
304        Self::load_from_path(&Self::path())
305    }
306
307    /// Load config from the default path, returning defaults when the file is absent.
308    ///
309    /// Returns an error if the file exists but cannot be read or parsed.
310    pub fn load_or_default() -> Result<Self> {
311        Self::load_from_path_or_default(&Self::path())
312    }
313
314    /// Load config from an explicit path.
315    pub fn load_from_path(path: &Path) -> Result<Self> {
316        let content = fs::read_to_string(path)
317            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
318        toml::from_str(&content)
319            .with_context(|| format!("Failed to parse config file: {}", path.display()))
320    }
321
322    /// Load config from an explicit path, returning defaults when the file is absent.
323    pub fn load_from_path_or_default(path: &Path) -> Result<Self> {
324        if !path.exists() {
325            return Ok(Self::default());
326        }
327
328        Self::load_from_path(path)
329    }
330
331    /// Load config from the default path, logging a warning and falling back to defaults on error.
332    ///
333    /// This helper is for non-fatal UI paths that should remain usable while still surfacing
334    /// broken config files in the logs.
335    pub fn load_or_default_logged() -> Self {
336        match Self::load_or_default() {
337            Ok(config) => config,
338            Err(err) => {
339                tracing::warn!("Failed to load config file: {err:#}");
340                Self::default()
341            }
342        }
343    }
344
345    /// Save config to file
346    pub fn save(&self) -> Result<()> {
347        let path = Self::path();
348        if let Some(parent) = path.parent() {
349            fs::create_dir_all(parent).with_context(|| {
350                format!("Failed to create config directory: {}", parent.display())
351            })?;
352        }
353        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
354        fs::write(&path, content)
355            .with_context(|| format!("Failed to write config: {}", path.display()))?;
356        Ok(())
357    }
358}
359
360/// Resolve multiple devices, applying alias resolution to each.
361/// Returns an empty Vec if no devices are specified.
362pub fn resolve_devices(devices: Vec<String>, config: &Config) -> Vec<String> {
363    devices
364        .into_iter()
365        .map(|d| resolve_alias(&d, config))
366        .collect()
367}
368
369/// Resolve an alias to its device address, or return the original if not an alias.
370pub fn resolve_alias(device: &str, config: &Config) -> String {
371    config
372        .aliases
373        .get(device)
374        .cloned()
375        .unwrap_or_else(|| device.to_string())
376}
377
378/// Resolve an alias and return information about the resolution.
379/// Returns (resolved_address, was_alias, original_alias_name).
380pub fn resolve_alias_with_info(device: &str, config: &Config) -> (String, bool, Option<String>) {
381    if let Some(address) = config.aliases.get(device) {
382        (address.clone(), true, Some(device.to_string()))
383    } else {
384        (device.to_string(), false, None)
385    }
386}
387
388/// Print alias resolution feedback if the user is not in quiet mode.
389/// Call this after resolving an alias to inform the user which device was selected.
390pub fn print_alias_feedback(original: &str, resolved: &str, quiet: bool) {
391    if !quiet && original != resolved {
392        eprintln!("Using device '{}' -> {}", original, resolved);
393    }
394}
395
396/// Print device source feedback (e.g., "Using last connected device: ...").
397pub fn print_device_source_feedback(device: &str, source: Option<&str>, quiet: bool) {
398    if quiet {
399        return;
400    }
401    match source {
402        Some("default") => eprintln!("Using default device: {}", device),
403        Some("last") => eprintln!("Using last connected device: {}", device),
404        Some("store") => eprintln!("Using known device from database: {}", device),
405        _ => {}
406    }
407}
408
409/// Update the last connected device in config.
410/// This is called after a successful connection.
411pub fn update_last_device(identifier: &str, name: Option<&str>) -> Result<()> {
412    let mut config = Config::load_or_default()?;
413    config.last_device = Some(identifier.to_string());
414    config.last_device_name = name.map(|n| n.to_string());
415    config.save()
416}
417
418/// Get info about whether we're using a fallback device.
419/// Returns (device_identifier, fallback_source) where fallback_source is:
420/// - None if device was explicitly provided
421/// - Some("default") if using default device
422/// - Some("last") if using last connected device
423/// - Some("store") if using known device from database
424pub fn get_device_source(
425    device: Option<&str>,
426    config: &Config,
427) -> (Option<String>, Option<&'static str>) {
428    if let Some(d) = device {
429        (Some(resolve_alias(d, config)), None)
430    } else if let Some(d) = &config.device {
431        (Some(d.clone()), Some("default"))
432    } else if let Some(d) = &config.last_device {
433        (Some(d.clone()), Some("last"))
434    } else if config.behavior.load_cache {
435        // Try to get a known device from the store
436        if let Some(d) = get_first_known_device() {
437            (Some(d), Some("store"))
438        } else {
439            (None, None)
440        }
441    } else {
442        (None, None)
443    }
444}
445
446/// Get the first known device from the store database.
447///
448/// Returns the device ID of the most recently connected device in the store,
449/// or None if the store is empty or cannot be opened.
450fn get_first_known_device() -> Option<String> {
451    let store_path = aranet_store::default_db_path();
452    let store = aranet_store::Store::open(&store_path).ok()?;
453    let devices = store.list_devices().ok()?;
454    devices.first().map(|d| d.id.clone())
455}
456
457/// Resolve timeout: use provided value, fall back to config, then default.
458pub fn resolve_timeout(cmd_timeout: Option<u64>, config: &Config, default: u64) -> u64 {
459    cmd_timeout.or(config.timeout).unwrap_or(default)
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use tempfile::tempdir;
466
467    #[test]
468    fn test_resolve_timeout_uses_explicit_value() {
469        let config = Config {
470            timeout: Some(60),
471            ..Default::default()
472        };
473        let result = resolve_timeout(Some(45), &config, 30);
474        assert_eq!(result, 45);
475    }
476
477    #[test]
478    fn test_resolve_timeout_uses_config_when_missing() {
479        let config = Config {
480            timeout: Some(60),
481            ..Default::default()
482        };
483        let result = resolve_timeout(None, &config, 30);
484        assert_eq!(result, 60);
485    }
486
487    #[test]
488    fn test_resolve_timeout_uses_default_when_no_config() {
489        let config = Config::default();
490        let result = resolve_timeout(None, &config, 30);
491        assert_eq!(result, 30);
492    }
493
494    #[test]
495    fn test_behavior_config_defaults_to_true() {
496        let behavior = BehaviorConfig::default();
497        assert!(behavior.auto_connect);
498        assert!(behavior.auto_sync);
499        assert!(behavior.remember_devices);
500        assert!(behavior.load_cache);
501    }
502
503    #[test]
504    fn test_config_has_default_behavior() {
505        let config = Config::default();
506        assert!(config.behavior.auto_connect);
507        assert!(config.behavior.auto_sync);
508        assert!(config.behavior.remember_devices);
509        assert!(config.behavior.load_cache);
510    }
511
512    #[test]
513    fn test_load_from_path_or_default_returns_default_when_missing() {
514        let dir = tempdir().unwrap();
515        let path = dir.path().join("missing.toml");
516
517        let config = Config::load_from_path_or_default(&path).unwrap();
518        assert!(config.device.is_none());
519        assert!(config.aliases.is_empty());
520    }
521
522    #[test]
523    fn test_load_from_path_reports_parse_errors() {
524        let dir = tempdir().unwrap();
525        let path = dir.path().join("config.toml");
526        fs::write(&path, "device = [").unwrap();
527
528        let err = Config::load_from_path(&path).unwrap_err();
529        assert!(err.to_string().contains("Failed to parse config file"));
530    }
531
532    #[test]
533    fn test_load_from_path_reads_valid_config() {
534        let dir = tempdir().unwrap();
535        let path = dir.path().join("config.toml");
536        fs::write(
537            &path,
538            r#"
539device = "Aranet4 12345"
540fahrenheit = true
541
542[aliases]
543office = "Aranet4 12345"
544"#,
545        )
546        .unwrap();
547
548        let config = Config::load_from_path(&path).unwrap();
549        assert_eq!(config.device.as_deref(), Some("Aranet4 12345"));
550        assert!(config.fahrenheit);
551        assert_eq!(
552            config.aliases.get("office").map(String::as_str),
553            Some("Aranet4 12345")
554        );
555    }
556
557    #[test]
558    fn test_behavior_config_serialization() {
559        let behavior = BehaviorConfig {
560            auto_connect: false,
561            auto_sync: true,
562            remember_devices: false,
563            load_cache: true,
564        };
565        let toml_str = toml::to_string(&behavior).unwrap();
566        assert!(toml_str.contains("auto_connect = false"));
567        assert!(toml_str.contains("auto_sync = true"));
568
569        // Deserialize back
570        let parsed: BehaviorConfig = toml::from_str(&toml_str).unwrap();
571        assert!(!parsed.auto_connect);
572        assert!(parsed.auto_sync);
573        assert!(!parsed.remember_devices);
574        assert!(parsed.load_cache);
575    }
576
577    // ========================================================================
578    // resolve_alias tests
579    // ========================================================================
580
581    #[test]
582    fn test_resolve_alias_found() {
583        let mut aliases = std::collections::HashMap::new();
584        aliases.insert("living-room".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
585        aliases.insert("bedroom".to_string(), "11:22:33:44:55:66".to_string());
586
587        let config = Config {
588            aliases,
589            ..Default::default()
590        };
591
592        let result = resolve_alias("living-room", &config);
593        assert_eq!(result, "AA:BB:CC:DD:EE:FF");
594    }
595
596    #[test]
597    fn test_resolve_alias_not_found() {
598        let config = Config::default();
599        let result = resolve_alias("unknown-alias", &config);
600        assert_eq!(result, "unknown-alias");
601    }
602
603    #[test]
604    fn test_resolve_alias_empty_aliases() {
605        let config = Config::default();
606        let result = resolve_alias("some-device", &config);
607        assert_eq!(result, "some-device");
608    }
609
610    #[test]
611    fn test_resolve_alias_returns_address_unchanged() {
612        let config = Config::default();
613        // If you pass an actual address, it should return unchanged
614        let result = resolve_alias("AA:BB:CC:DD:EE:FF", &config);
615        assert_eq!(result, "AA:BB:CC:DD:EE:FF");
616    }
617
618    // ========================================================================
619    // resolve_devices tests
620    // ========================================================================
621
622    #[test]
623    fn test_resolve_devices_empty() {
624        let config = Config::default();
625        let result = resolve_devices(vec![], &config);
626        assert!(result.is_empty());
627    }
628
629    #[test]
630    fn test_resolve_devices_multiple() {
631        let mut aliases = std::collections::HashMap::new();
632        aliases.insert("room1".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
633        aliases.insert("room2".to_string(), "11:22:33:44:55:66".to_string());
634
635        let config = Config {
636            aliases,
637            ..Default::default()
638        };
639
640        let devices = vec![
641            "room1".to_string(),
642            "room2".to_string(),
643            "direct-address".to_string(),
644        ];
645        let result = resolve_devices(devices, &config);
646
647        assert_eq!(result.len(), 3);
648        assert_eq!(result[0], "AA:BB:CC:DD:EE:FF");
649        assert_eq!(result[1], "11:22:33:44:55:66");
650        assert_eq!(result[2], "direct-address");
651    }
652
653    #[test]
654    fn test_resolve_devices_no_aliases() {
655        let config = Config::default();
656        let devices = vec!["device1".to_string(), "device2".to_string()];
657        let result = resolve_devices(devices, &config);
658
659        assert_eq!(result.len(), 2);
660        assert_eq!(result[0], "device1");
661        assert_eq!(result[1], "device2");
662    }
663
664    // ========================================================================
665    // get_device_source tests
666    // ========================================================================
667
668    #[test]
669    fn test_get_device_source_explicit() {
670        let config = Config::default();
671        let (device, source) = get_device_source(Some("explicit-device"), &config);
672
673        assert_eq!(device, Some("explicit-device".to_string()));
674        assert_eq!(source, None); // No fallback source when explicit
675    }
676
677    #[test]
678    fn test_get_device_source_from_default() {
679        let config = Config {
680            device: Some("default-device".to_string()),
681            ..Default::default()
682        };
683        let (device, source) = get_device_source(None, &config);
684
685        assert_eq!(device, Some("default-device".to_string()));
686        assert_eq!(source, Some("default"));
687    }
688
689    #[test]
690    fn test_get_device_source_from_last() {
691        let config = Config {
692            last_device: Some("last-device".to_string()),
693            ..Default::default()
694        };
695        let (device, source) = get_device_source(None, &config);
696
697        assert_eq!(device, Some("last-device".to_string()));
698        assert_eq!(source, Some("last"));
699    }
700
701    #[test]
702    fn test_get_device_source_prefers_default_over_last() {
703        let config = Config {
704            device: Some("default-device".to_string()),
705            last_device: Some("last-device".to_string()),
706            ..Default::default()
707        };
708        let (device, source) = get_device_source(None, &config);
709
710        // Default should take precedence over last
711        assert_eq!(device, Some("default-device".to_string()));
712        assert_eq!(source, Some("default"));
713    }
714
715    #[test]
716    fn test_get_device_source_resolves_alias() {
717        let mut aliases = std::collections::HashMap::new();
718        aliases.insert("my-sensor".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
719
720        let config = Config {
721            aliases,
722            ..Default::default()
723        };
724        let (device, source) = get_device_source(Some("my-sensor"), &config);
725
726        assert_eq!(device, Some("AA:BB:CC:DD:EE:FF".to_string()));
727        assert_eq!(source, None);
728    }
729}