1use std::collections::HashMap;
4use std::fs;
5use std::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 = "default_true")]
158 pub show_co2: bool,
159
160 #[serde(default = "default_true")]
162 pub show_temperature: bool,
163
164 #[serde(default = "default_true")]
166 pub show_humidity: bool,
167
168 #[serde(default = "default_true")]
170 pub show_pressure: bool,
171
172 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct BehaviorConfig {
251 #[serde(default = "default_true")]
253 pub auto_connect: bool,
254
255 #[serde(default = "default_true")]
257 pub auto_sync: bool,
258
259 #[serde(default = "default_true")]
261 pub remember_devices: bool,
262
263 #[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 pub fn path() -> PathBuf {
286 dirs::config_dir()
287 .unwrap_or_else(|| PathBuf::from("."))
288 .join("aranet")
289 .join("config.toml")
290 }
291
292 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 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#[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
337pub 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
346pub 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
355pub 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
365pub fn print_alias_feedback(original: &str, resolved: &str, quiet: bool) {
368 if !quiet && original != resolved {
369 eprintln!("Using device '{}' -> {}", original, resolved);
370 }
371}
372
373pub 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
386pub 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
395pub 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 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
423fn 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
434pub fn resolve_timeout(cmd_timeout: u64, config: &Config, default: u64) -> u64 {
436 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 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 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 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 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 #[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 let result = resolve_alias("AA:BB:CC:DD:EE:FF", &config);
582 assert_eq!(result, "AA:BB:CC:DD:EE:FF");
583 }
584
585 #[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 #[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); }
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 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}