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