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
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#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct BehaviorConfig {
246 #[serde(default = "default_true")]
248 pub auto_connect: bool,
249
250 #[serde(default = "default_true")]
252 pub auto_sync: bool,
253
254 #[serde(default = "default_true")]
256 pub remember_devices: bool,
257
258 #[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 pub fn path() -> PathBuf {
281 dirs::config_dir()
282 .unwrap_or_else(|| PathBuf::from("."))
283 .join("aranet")
284 .join("config.toml")
285 }
286
287 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 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#[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
332pub 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
341pub 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
350pub 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
360pub fn print_alias_feedback(original: &str, resolved: &str, quiet: bool) {
363 if !quiet && original != resolved {
364 eprintln!("Using device '{}' -> {}", original, resolved);
365 }
366}
367
368pub 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
381pub 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
390pub 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 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
418fn 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
429pub fn resolve_timeout(cmd_timeout: u64, config: &Config, default: u64) -> u64 {
431 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 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 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 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 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 #[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 let result = resolve_alias("AA:BB:CC:DD:EE:FF", &config);
577 assert_eq!(result, "AA:BB:CC:DD:EE:FF");
578 }
579
580 #[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 #[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); }
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 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}