1use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Config {
13 #[serde(default)]
15 pub device: Option<String>,
16
17 #[serde(default)]
19 pub format: Option<String>,
20
21 #[serde(default)]
23 pub no_color: bool,
24
25 #[serde(default)]
27 pub fahrenheit: bool,
28
29 #[serde(default)]
31 pub inhg: bool,
32
33 #[serde(default)]
35 pub bq: bool,
36
37 #[serde(default)]
39 pub timeout: Option<u64>,
40
41 #[serde(default)]
43 pub aliases: HashMap<String, String>,
44
45 #[serde(default)]
47 pub last_device: Option<String>,
48
49 #[serde(default)]
51 pub last_device_name: Option<String>,
52
53 #[serde(default)]
55 pub behavior: BehaviorConfig,
56
57 #[serde(default)]
59 pub gui: GuiConfig,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct GuiConfig {
67 #[serde(default = "default_theme")]
69 pub theme: String,
70
71 #[serde(default = "default_true")]
75 pub colored_tray_icon: bool,
76
77 #[serde(default = "default_true")]
79 pub notifications_enabled: bool,
80
81 #[serde(default = "default_true")]
83 pub notification_sound: bool,
84
85 #[serde(default)]
87 pub start_minimized: bool,
88
89 #[serde(default = "default_true")]
91 pub close_to_tray: bool,
92
93 #[serde(default = "default_celsius")]
96 pub temperature_unit: String,
97
98 #[serde(default = "default_hpa")]
101 pub pressure_unit: String,
102
103 #[serde(default)]
105 pub sidebar_collapsed: bool,
106
107 #[serde(default)]
109 pub compact_mode: bool,
110
111 #[serde(default)]
113 pub window_width: Option<f32>,
114
115 #[serde(default)]
117 pub window_height: Option<f32>,
118
119 #[serde(default)]
121 pub window_x: Option<f32>,
122
123 #[serde(default)]
125 pub window_y: Option<f32>,
126
127 #[serde(default = "default_co2_warning")]
129 pub co2_warning_threshold: u16,
130
131 #[serde(default = "default_co2_danger")]
133 pub co2_danger_threshold: u16,
134
135 #[serde(default = "default_radon_warning")]
137 pub radon_warning_threshold: u32,
138
139 #[serde(default = "default_radon_danger")]
141 pub radon_danger_threshold: u32,
142
143 #[serde(default = "default_export_format")]
145 pub default_export_format: String,
146
147 #[serde(default)]
149 pub export_directory: String,
150
151 #[serde(default = "default_service_url")]
154 pub service_url: String,
155
156 #[serde(default)]
158 pub service_api_key: Option<String>,
159
160 #[serde(default = "default_true")]
162 pub show_co2: bool,
163
164 #[serde(default = "default_true")]
166 pub show_temperature: bool,
167
168 #[serde(default = "default_true")]
170 pub show_humidity: bool,
171
172 #[serde(default = "default_true")]
174 pub show_pressure: bool,
175
176 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct BehaviorConfig {
256 #[serde(default = "default_true")]
258 pub auto_connect: bool,
259
260 #[serde(default = "default_true")]
262 pub auto_sync: bool,
263
264 #[serde(default = "default_true")]
266 pub remember_devices: bool,
267
268 #[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 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 pub fn load() -> Result<Self> {
304 Self::load_from_path(&Self::path())
305 }
306
307 pub fn load_or_default() -> Result<Self> {
311 Self::load_from_path_or_default(&Self::path())
312 }
313
314 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 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 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 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
360pub 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
369pub 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
378pub 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
388pub fn print_alias_feedback(original: &str, resolved: &str, quiet: bool) {
391 if !quiet && original != resolved {
392 eprintln!("Using device '{}' -> {}", original, resolved);
393 }
394}
395
396pub 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
409pub 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
418pub 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 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
446fn 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
457pub 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 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 #[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 let result = resolve_alias("AA:BB:CC:DD:EE:FF", &config);
615 assert_eq!(result, "AA:BB:CC:DD:EE:FF");
616 }
617
618 #[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 #[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); }
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 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}