Skip to main content

aranet_cli/
config.rs

1//! Configuration file management.
2
3use std::collections::HashMap;
4use std::fs;
5use std::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    /// Show CO2 readings in dashboard.
157    #[serde(default = "default_true")]
158    pub show_co2: bool,
159
160    /// Show temperature readings in dashboard.
161    #[serde(default = "default_true")]
162    pub show_temperature: bool,
163
164    /// Show humidity readings in dashboard.
165    #[serde(default = "default_true")]
166    pub show_humidity: bool,
167
168    /// Show pressure readings in dashboard.
169    #[serde(default = "default_true")]
170    pub show_pressure: bool,
171
172    /// Do Not Disturb mode - suppress all notifications.
173    #[serde(default)]
174    pub do_not_disturb: bool,
175}
176
177fn default_service_url() -> String {
178    "http://localhost:8080".to_string()
179}
180
181fn default_theme() -> String {
182    "dark".to_string()
183}
184
185fn default_celsius() -> String {
186    "celsius".to_string()
187}
188
189fn default_hpa() -> String {
190    "hpa".to_string()
191}
192
193fn default_co2_warning() -> u16 {
194    1000
195}
196
197fn default_co2_danger() -> u16 {
198    1400
199}
200
201fn default_radon_warning() -> u32 {
202    100
203}
204
205fn default_radon_danger() -> u32 {
206    150
207}
208
209fn default_export_format() -> String {
210    "csv".to_string()
211}
212
213impl Default for GuiConfig {
214    fn default() -> Self {
215        Self {
216            theme: default_theme(),
217            colored_tray_icon: true,
218            notifications_enabled: true,
219            notification_sound: true,
220            start_minimized: false,
221            close_to_tray: true,
222            temperature_unit: default_celsius(),
223            pressure_unit: default_hpa(),
224            sidebar_collapsed: false,
225            compact_mode: false,
226            window_width: None,
227            window_height: None,
228            window_x: None,
229            window_y: None,
230            co2_warning_threshold: default_co2_warning(),
231            co2_danger_threshold: default_co2_danger(),
232            radon_warning_threshold: default_radon_warning(),
233            radon_danger_threshold: default_radon_danger(),
234            default_export_format: default_export_format(),
235            export_directory: String::new(),
236            service_url: default_service_url(),
237            show_co2: true,
238            show_temperature: true,
239            show_humidity: true,
240            show_pressure: true,
241            do_not_disturb: false,
242        }
243    }
244}
245
246/// Behavior configuration for unified data architecture.
247///
248/// Controls automatic connection, sync, and device memory across all tools.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct BehaviorConfig {
251    /// Auto-connect to known devices on startup (TUI/GUI)
252    #[serde(default = "default_true")]
253    pub auto_connect: bool,
254
255    /// Auto-sync history on connection
256    #[serde(default = "default_true")]
257    pub auto_sync: bool,
258
259    /// Remember devices in database after connection
260    #[serde(default = "default_true")]
261    pub remember_devices: bool,
262
263    /// Load cached data (devices, readings) on startup
264    #[serde(default = "default_true")]
265    pub load_cache: bool,
266}
267
268fn default_true() -> bool {
269    true
270}
271
272impl Default for BehaviorConfig {
273    fn default() -> Self {
274        Self {
275            auto_connect: true,
276            auto_sync: true,
277            remember_devices: true,
278            load_cache: true,
279        }
280    }
281}
282
283impl Config {
284    /// Get the config file path
285    pub fn path() -> PathBuf {
286        dirs::config_dir()
287            .unwrap_or_else(|| PathBuf::from("."))
288            .join("aranet")
289            .join("config.toml")
290    }
291
292    /// Load config from file, or return default if not found
293    pub fn load() -> Self {
294        let path = Self::path();
295        if path.exists() {
296            match fs::read_to_string(&path) {
297                Ok(content) => match toml::from_str(&content) {
298                    Ok(config) => return config,
299                    Err(e) => {
300                        eprintln!("Warning: Failed to parse config: {}", e);
301                    }
302                },
303                Err(e) => {
304                    eprintln!("Warning: Failed to read config: {}", e);
305                }
306            }
307        }
308        Self::default()
309    }
310
311    /// Save config to file
312    pub fn save(&self) -> Result<()> {
313        let path = Self::path();
314        if let Some(parent) = path.parent() {
315            fs::create_dir_all(parent).with_context(|| {
316                format!("Failed to create config directory: {}", parent.display())
317            })?;
318        }
319        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
320        fs::write(&path, content)
321            .with_context(|| format!("Failed to write config: {}", path.display()))?;
322        Ok(())
323    }
324}
325
326/// Resolve device from arg, env var, or config.
327/// Also resolves aliases: if the device matches an alias name, returns the address.
328/// Falls back to last_device if no default device is set.
329#[allow(dead_code)]
330pub fn resolve_device(device: Option<String>, config: &Config) -> Option<String> {
331    device
332        .map(|d| resolve_alias(&d, config))
333        .or_else(|| config.device.clone())
334        .or_else(|| config.last_device.clone())
335}
336
337/// Resolve multiple devices, applying alias resolution to each.
338/// Returns an empty Vec if no devices are specified.
339pub fn resolve_devices(devices: Vec<String>, config: &Config) -> Vec<String> {
340    devices
341        .into_iter()
342        .map(|d| resolve_alias(&d, config))
343        .collect()
344}
345
346/// Resolve an alias to its device address, or return the original if not an alias.
347pub fn resolve_alias(device: &str, config: &Config) -> String {
348    config
349        .aliases
350        .get(device)
351        .cloned()
352        .unwrap_or_else(|| device.to_string())
353}
354
355/// Resolve an alias and return information about the resolution.
356/// Returns (resolved_address, was_alias, original_alias_name).
357pub fn resolve_alias_with_info(device: &str, config: &Config) -> (String, bool, Option<String>) {
358    if let Some(address) = config.aliases.get(device) {
359        (address.clone(), true, Some(device.to_string()))
360    } else {
361        (device.to_string(), false, None)
362    }
363}
364
365/// Print alias resolution feedback if the user is not in quiet mode.
366/// Call this after resolving an alias to inform the user which device was selected.
367pub fn print_alias_feedback(original: &str, resolved: &str, quiet: bool) {
368    if !quiet && original != resolved {
369        eprintln!("Using device '{}' -> {}", original, resolved);
370    }
371}
372
373/// Print device source feedback (e.g., "Using last connected device: ...").
374pub fn print_device_source_feedback(device: &str, source: Option<&str>, quiet: bool) {
375    if quiet {
376        return;
377    }
378    match source {
379        Some("default") => eprintln!("Using default device: {}", device),
380        Some("last") => eprintln!("Using last connected device: {}", device),
381        Some("store") => eprintln!("Using known device from database: {}", device),
382        _ => {}
383    }
384}
385
386/// Update the last connected device in config.
387/// This is called after a successful connection.
388pub fn update_last_device(identifier: &str, name: Option<&str>) -> Result<()> {
389    let mut config = Config::load();
390    config.last_device = Some(identifier.to_string());
391    config.last_device_name = name.map(|n| n.to_string());
392    config.save()
393}
394
395/// Get info about whether we're using a fallback device.
396/// Returns (device_identifier, fallback_source) where fallback_source is:
397/// - None if device was explicitly provided
398/// - Some("default") if using default device
399/// - Some("last") if using last connected device
400/// - Some("store") if using known device from database
401pub fn get_device_source(
402    device: Option<&str>,
403    config: &Config,
404) -> (Option<String>, Option<&'static str>) {
405    if let Some(d) = device {
406        (Some(resolve_alias(d, config)), None)
407    } else if let Some(d) = &config.device {
408        (Some(d.clone()), Some("default"))
409    } else if let Some(d) = &config.last_device {
410        (Some(d.clone()), Some("last"))
411    } else if config.behavior.load_cache {
412        // Try to get a known device from the store
413        if let Some(d) = get_first_known_device() {
414            (Some(d), Some("store"))
415        } else {
416            (None, None)
417        }
418    } else {
419        (None, None)
420    }
421}
422
423/// Get the first known device from the store database.
424///
425/// Returns the device ID of the most recently connected device in the store,
426/// or None if the store is empty or cannot be opened.
427fn get_first_known_device() -> Option<String> {
428    let store_path = aranet_store::default_db_path();
429    let store = aranet_store::Store::open(&store_path).ok()?;
430    let devices = store.list_devices().ok()?;
431    devices.first().map(|d| d.id.clone())
432}
433
434/// Resolve timeout: use provided value, fall back to config, then default
435pub fn resolve_timeout(cmd_timeout: u64, config: &Config, default: u64) -> u64 {
436    // If the command timeout differs from clap's default, use it
437    // Otherwise, check config, then fall back to the provided default
438    if cmd_timeout != default {
439        cmd_timeout
440    } else {
441        config.timeout.unwrap_or(default)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_resolve_device_prefers_arg() {
451        let config = Config {
452            device: Some("config-device".to_string()),
453            ..Default::default()
454        };
455        let result = resolve_device(Some("arg-device".to_string()), &config);
456        assert_eq!(result, Some("arg-device".to_string()));
457    }
458
459    #[test]
460    fn test_resolve_device_falls_back_to_config() {
461        let config = Config {
462            device: Some("config-device".to_string()),
463            ..Default::default()
464        };
465        let result = resolve_device(None, &config);
466        assert_eq!(result, Some("config-device".to_string()));
467    }
468
469    #[test]
470    fn test_resolve_device_none_when_both_empty() {
471        let config = Config::default();
472        let result = resolve_device(None, &config);
473        assert_eq!(result, None);
474    }
475
476    #[test]
477    fn test_resolve_timeout_uses_explicit_value() {
478        let config = Config {
479            timeout: Some(60),
480            ..Default::default()
481        };
482        // Explicit value differs from default, so use it
483        let result = resolve_timeout(45, &config, 30);
484        assert_eq!(result, 45);
485    }
486
487    #[test]
488    fn test_resolve_timeout_uses_config_when_default() {
489        let config = Config {
490            timeout: Some(60),
491            ..Default::default()
492        };
493        // Value equals default, so use config
494        let result = resolve_timeout(30, &config, 30);
495        assert_eq!(result, 60);
496    }
497
498    #[test]
499    fn test_resolve_timeout_uses_default_when_no_config() {
500        let config = Config::default();
501        // Value equals default and no config, so use default
502        let result = resolve_timeout(30, &config, 30);
503        assert_eq!(result, 30);
504    }
505
506    #[test]
507    fn test_behavior_config_defaults_to_true() {
508        let behavior = BehaviorConfig::default();
509        assert!(behavior.auto_connect);
510        assert!(behavior.auto_sync);
511        assert!(behavior.remember_devices);
512        assert!(behavior.load_cache);
513    }
514
515    #[test]
516    fn test_config_has_default_behavior() {
517        let config = Config::default();
518        assert!(config.behavior.auto_connect);
519        assert!(config.behavior.auto_sync);
520        assert!(config.behavior.remember_devices);
521        assert!(config.behavior.load_cache);
522    }
523
524    #[test]
525    fn test_behavior_config_serialization() {
526        let behavior = BehaviorConfig {
527            auto_connect: false,
528            auto_sync: true,
529            remember_devices: false,
530            load_cache: true,
531        };
532        let toml_str = toml::to_string(&behavior).unwrap();
533        assert!(toml_str.contains("auto_connect = false"));
534        assert!(toml_str.contains("auto_sync = true"));
535
536        // Deserialize back
537        let parsed: BehaviorConfig = toml::from_str(&toml_str).unwrap();
538        assert!(!parsed.auto_connect);
539        assert!(parsed.auto_sync);
540        assert!(!parsed.remember_devices);
541        assert!(parsed.load_cache);
542    }
543
544    // ========================================================================
545    // resolve_alias tests
546    // ========================================================================
547
548    #[test]
549    fn test_resolve_alias_found() {
550        let mut aliases = std::collections::HashMap::new();
551        aliases.insert("living-room".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
552        aliases.insert("bedroom".to_string(), "11:22:33:44:55:66".to_string());
553
554        let config = Config {
555            aliases,
556            ..Default::default()
557        };
558
559        let result = resolve_alias("living-room", &config);
560        assert_eq!(result, "AA:BB:CC:DD:EE:FF");
561    }
562
563    #[test]
564    fn test_resolve_alias_not_found() {
565        let config = Config::default();
566        let result = resolve_alias("unknown-alias", &config);
567        assert_eq!(result, "unknown-alias");
568    }
569
570    #[test]
571    fn test_resolve_alias_empty_aliases() {
572        let config = Config::default();
573        let result = resolve_alias("some-device", &config);
574        assert_eq!(result, "some-device");
575    }
576
577    #[test]
578    fn test_resolve_alias_returns_address_unchanged() {
579        let config = Config::default();
580        // If you pass an actual address, it should return unchanged
581        let result = resolve_alias("AA:BB:CC:DD:EE:FF", &config);
582        assert_eq!(result, "AA:BB:CC:DD:EE:FF");
583    }
584
585    // ========================================================================
586    // resolve_devices tests
587    // ========================================================================
588
589    #[test]
590    fn test_resolve_devices_empty() {
591        let config = Config::default();
592        let result = resolve_devices(vec![], &config);
593        assert!(result.is_empty());
594    }
595
596    #[test]
597    fn test_resolve_devices_multiple() {
598        let mut aliases = std::collections::HashMap::new();
599        aliases.insert("room1".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
600        aliases.insert("room2".to_string(), "11:22:33:44:55:66".to_string());
601
602        let config = Config {
603            aliases,
604            ..Default::default()
605        };
606
607        let devices = vec![
608            "room1".to_string(),
609            "room2".to_string(),
610            "direct-address".to_string(),
611        ];
612        let result = resolve_devices(devices, &config);
613
614        assert_eq!(result.len(), 3);
615        assert_eq!(result[0], "AA:BB:CC:DD:EE:FF");
616        assert_eq!(result[1], "11:22:33:44:55:66");
617        assert_eq!(result[2], "direct-address");
618    }
619
620    #[test]
621    fn test_resolve_devices_no_aliases() {
622        let config = Config::default();
623        let devices = vec!["device1".to_string(), "device2".to_string()];
624        let result = resolve_devices(devices, &config);
625
626        assert_eq!(result.len(), 2);
627        assert_eq!(result[0], "device1");
628        assert_eq!(result[1], "device2");
629    }
630
631    // ========================================================================
632    // get_device_source tests
633    // ========================================================================
634
635    #[test]
636    fn test_get_device_source_explicit() {
637        let config = Config::default();
638        let (device, source) = get_device_source(Some("explicit-device"), &config);
639
640        assert_eq!(device, Some("explicit-device".to_string()));
641        assert_eq!(source, None); // No fallback source when explicit
642    }
643
644    #[test]
645    fn test_get_device_source_from_default() {
646        let config = Config {
647            device: Some("default-device".to_string()),
648            ..Default::default()
649        };
650        let (device, source) = get_device_source(None, &config);
651
652        assert_eq!(device, Some("default-device".to_string()));
653        assert_eq!(source, Some("default"));
654    }
655
656    #[test]
657    fn test_get_device_source_from_last() {
658        let config = Config {
659            last_device: Some("last-device".to_string()),
660            ..Default::default()
661        };
662        let (device, source) = get_device_source(None, &config);
663
664        assert_eq!(device, Some("last-device".to_string()));
665        assert_eq!(source, Some("last"));
666    }
667
668    #[test]
669    fn test_get_device_source_prefers_default_over_last() {
670        let config = Config {
671            device: Some("default-device".to_string()),
672            last_device: Some("last-device".to_string()),
673            ..Default::default()
674        };
675        let (device, source) = get_device_source(None, &config);
676
677        // Default should take precedence over last
678        assert_eq!(device, Some("default-device".to_string()));
679        assert_eq!(source, Some("default"));
680    }
681
682    #[test]
683    fn test_get_device_source_resolves_alias() {
684        let mut aliases = std::collections::HashMap::new();
685        aliases.insert("my-sensor".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
686
687        let config = Config {
688            aliases,
689            ..Default::default()
690        };
691        let (device, source) = get_device_source(Some("my-sensor"), &config);
692
693        assert_eq!(device, Some("AA:BB:CC:DD:EE:FF".to_string()));
694        assert_eq!(source, None);
695    }
696}