use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub device: Option<String>,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub no_color: bool,
#[serde(default)]
pub fahrenheit: bool,
#[serde(default)]
pub inhg: bool,
#[serde(default)]
pub bq: bool,
#[serde(default)]
pub timeout: Option<u64>,
#[serde(default)]
pub aliases: HashMap<String, String>,
#[serde(default)]
pub last_device: Option<String>,
#[serde(default)]
pub last_device_name: Option<String>,
#[serde(default)]
pub behavior: BehaviorConfig,
#[serde(default)]
pub gui: GuiConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuiConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_true")]
pub colored_tray_icon: bool,
#[serde(default = "default_true")]
pub notifications_enabled: bool,
#[serde(default = "default_true")]
pub notification_sound: bool,
#[serde(default)]
pub start_minimized: bool,
#[serde(default = "default_true")]
pub close_to_tray: bool,
#[serde(default = "default_celsius")]
pub temperature_unit: String,
#[serde(default = "default_hpa")]
pub pressure_unit: String,
#[serde(default)]
pub sidebar_collapsed: bool,
#[serde(default)]
pub compact_mode: bool,
#[serde(default)]
pub window_width: Option<f32>,
#[serde(default)]
pub window_height: Option<f32>,
#[serde(default)]
pub window_x: Option<f32>,
#[serde(default)]
pub window_y: Option<f32>,
#[serde(default = "default_co2_warning")]
pub co2_warning_threshold: u16,
#[serde(default = "default_co2_danger")]
pub co2_danger_threshold: u16,
#[serde(default = "default_radon_warning")]
pub radon_warning_threshold: u32,
#[serde(default = "default_radon_danger")]
pub radon_danger_threshold: u32,
#[serde(default = "default_export_format")]
pub default_export_format: String,
#[serde(default)]
pub export_directory: String,
#[serde(default = "default_service_url")]
pub service_url: String,
#[serde(default)]
pub service_api_key: Option<String>,
#[serde(default = "default_true")]
pub show_co2: bool,
#[serde(default = "default_true")]
pub show_temperature: bool,
#[serde(default = "default_true")]
pub show_humidity: bool,
#[serde(default = "default_true")]
pub show_pressure: bool,
#[serde(default)]
pub do_not_disturb: bool,
}
fn default_service_url() -> String {
"http://localhost:8080".to_string()
}
fn default_theme() -> String {
"dark".to_string()
}
fn default_celsius() -> String {
"celsius".to_string()
}
fn default_hpa() -> String {
"hpa".to_string()
}
fn default_co2_warning() -> u16 {
1000
}
fn default_co2_danger() -> u16 {
1400
}
fn default_radon_warning() -> u32 {
100
}
fn default_radon_danger() -> u32 {
150
}
fn default_export_format() -> String {
"csv".to_string()
}
impl Default for GuiConfig {
fn default() -> Self {
Self {
theme: default_theme(),
colored_tray_icon: true,
notifications_enabled: true,
notification_sound: true,
start_minimized: false,
close_to_tray: true,
temperature_unit: default_celsius(),
pressure_unit: default_hpa(),
sidebar_collapsed: false,
compact_mode: false,
window_width: None,
window_height: None,
window_x: None,
window_y: None,
co2_warning_threshold: default_co2_warning(),
co2_danger_threshold: default_co2_danger(),
radon_warning_threshold: default_radon_warning(),
radon_danger_threshold: default_radon_danger(),
default_export_format: default_export_format(),
export_directory: String::new(),
service_url: default_service_url(),
service_api_key: None,
show_co2: true,
show_temperature: true,
show_humidity: true,
show_pressure: true,
do_not_disturb: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorConfig {
#[serde(default = "default_true")]
pub auto_connect: bool,
#[serde(default = "default_true")]
pub auto_sync: bool,
#[serde(default = "default_true")]
pub remember_devices: bool,
#[serde(default = "default_true")]
pub load_cache: bool,
}
fn default_true() -> bool {
true
}
impl Default for BehaviorConfig {
fn default() -> Self {
Self {
auto_connect: true,
auto_sync: true,
remember_devices: true,
load_cache: true,
}
}
}
impl Config {
pub fn path() -> PathBuf {
std::env::var_os("ARANET_CONFIG_DIR")
.map(PathBuf::from)
.or_else(|| dirs::config_dir().map(|d| d.join("aranet")))
.unwrap_or_else(|| PathBuf::from("."))
.join("config.toml")
}
pub fn load() -> Result<Self> {
Self::load_from_path(&Self::path())
}
pub fn load_or_default() -> Result<Self> {
Self::load_from_path_or_default(&Self::path())
}
pub fn load_from_path(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("Failed to parse config file: {}", path.display()))
}
pub fn load_from_path_or_default(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
Self::load_from_path(path)
}
pub fn load_or_default_logged() -> Self {
match Self::load_or_default() {
Ok(config) => config,
Err(err) => {
tracing::warn!("Failed to load config file: {err:#}");
Self::default()
}
}
}
pub fn save(&self) -> Result<()> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&path, content)
.with_context(|| format!("Failed to write config: {}", path.display()))?;
Ok(())
}
}
pub fn resolve_devices(devices: Vec<String>, config: &Config) -> Vec<String> {
devices
.into_iter()
.map(|d| resolve_alias(&d, config))
.collect()
}
pub fn resolve_alias(device: &str, config: &Config) -> String {
config
.aliases
.get(device)
.cloned()
.unwrap_or_else(|| device.to_string())
}
pub fn resolve_alias_with_info(device: &str, config: &Config) -> (String, bool, Option<String>) {
if let Some(address) = config.aliases.get(device) {
(address.clone(), true, Some(device.to_string()))
} else {
(device.to_string(), false, None)
}
}
pub fn print_alias_feedback(original: &str, resolved: &str, quiet: bool) {
if !quiet && original != resolved {
eprintln!("Using device '{}' -> {}", original, resolved);
}
}
pub fn print_device_source_feedback(device: &str, source: Option<&str>, quiet: bool) {
if quiet {
return;
}
match source {
Some("default") => eprintln!("Using default device: {}", device),
Some("last") => eprintln!("Using last connected device: {}", device),
Some("store") => eprintln!("Using known device from database: {}", device),
_ => {}
}
}
pub fn update_last_device(identifier: &str, name: Option<&str>) -> Result<()> {
let mut config = Config::load_or_default()?;
config.last_device = Some(identifier.to_string());
config.last_device_name = name.map(|n| n.to_string());
config.save()
}
pub fn get_device_source(
device: Option<&str>,
config: &Config,
) -> (Option<String>, Option<&'static str>) {
if let Some(d) = device {
(Some(resolve_alias(d, config)), None)
} else if let Some(d) = &config.device {
(Some(d.clone()), Some("default"))
} else if let Some(d) = &config.last_device {
(Some(d.clone()), Some("last"))
} else if config.behavior.load_cache {
if let Some(d) = get_first_known_device() {
(Some(d), Some("store"))
} else {
(None, None)
}
} else {
(None, None)
}
}
fn get_first_known_device() -> Option<String> {
let store_path = aranet_store::default_db_path();
let store = aranet_store::Store::open(&store_path).ok()?;
let devices = store.list_devices().ok()?;
devices.first().map(|d| d.id.clone())
}
pub fn resolve_timeout(cmd_timeout: Option<u64>, config: &Config, default: u64) -> u64 {
cmd_timeout.or(config.timeout).unwrap_or(default)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_resolve_timeout_uses_explicit_value() {
let config = Config {
timeout: Some(60),
..Default::default()
};
let result = resolve_timeout(Some(45), &config, 30);
assert_eq!(result, 45);
}
#[test]
fn test_resolve_timeout_uses_config_when_missing() {
let config = Config {
timeout: Some(60),
..Default::default()
};
let result = resolve_timeout(None, &config, 30);
assert_eq!(result, 60);
}
#[test]
fn test_resolve_timeout_uses_default_when_no_config() {
let config = Config::default();
let result = resolve_timeout(None, &config, 30);
assert_eq!(result, 30);
}
#[test]
fn test_behavior_config_defaults_to_true() {
let behavior = BehaviorConfig::default();
assert!(behavior.auto_connect);
assert!(behavior.auto_sync);
assert!(behavior.remember_devices);
assert!(behavior.load_cache);
}
#[test]
fn test_config_has_default_behavior() {
let config = Config::default();
assert!(config.behavior.auto_connect);
assert!(config.behavior.auto_sync);
assert!(config.behavior.remember_devices);
assert!(config.behavior.load_cache);
}
#[test]
fn test_load_from_path_or_default_returns_default_when_missing() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.toml");
let config = Config::load_from_path_or_default(&path).unwrap();
assert!(config.device.is_none());
assert!(config.aliases.is_empty());
}
#[test]
fn test_load_from_path_reports_parse_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "device = [").unwrap();
let err = Config::load_from_path(&path).unwrap_err();
assert!(err.to_string().contains("Failed to parse config file"));
}
#[test]
fn test_load_from_path_reads_valid_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
device = "Aranet4 12345"
fahrenheit = true
[aliases]
office = "Aranet4 12345"
"#,
)
.unwrap();
let config = Config::load_from_path(&path).unwrap();
assert_eq!(config.device.as_deref(), Some("Aranet4 12345"));
assert!(config.fahrenheit);
assert_eq!(
config.aliases.get("office").map(String::as_str),
Some("Aranet4 12345")
);
}
#[test]
fn test_behavior_config_serialization() {
let behavior = BehaviorConfig {
auto_connect: false,
auto_sync: true,
remember_devices: false,
load_cache: true,
};
let toml_str = toml::to_string(&behavior).unwrap();
assert!(toml_str.contains("auto_connect = false"));
assert!(toml_str.contains("auto_sync = true"));
let parsed: BehaviorConfig = toml::from_str(&toml_str).unwrap();
assert!(!parsed.auto_connect);
assert!(parsed.auto_sync);
assert!(!parsed.remember_devices);
assert!(parsed.load_cache);
}
#[test]
fn test_resolve_alias_found() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("living-room".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
aliases.insert("bedroom".to_string(), "11:22:33:44:55:66".to_string());
let config = Config {
aliases,
..Default::default()
};
let result = resolve_alias("living-room", &config);
assert_eq!(result, "AA:BB:CC:DD:EE:FF");
}
#[test]
fn test_resolve_alias_not_found() {
let config = Config::default();
let result = resolve_alias("unknown-alias", &config);
assert_eq!(result, "unknown-alias");
}
#[test]
fn test_resolve_alias_empty_aliases() {
let config = Config::default();
let result = resolve_alias("some-device", &config);
assert_eq!(result, "some-device");
}
#[test]
fn test_resolve_alias_returns_address_unchanged() {
let config = Config::default();
let result = resolve_alias("AA:BB:CC:DD:EE:FF", &config);
assert_eq!(result, "AA:BB:CC:DD:EE:FF");
}
#[test]
fn test_resolve_devices_empty() {
let config = Config::default();
let result = resolve_devices(vec![], &config);
assert!(result.is_empty());
}
#[test]
fn test_resolve_devices_multiple() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("room1".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
aliases.insert("room2".to_string(), "11:22:33:44:55:66".to_string());
let config = Config {
aliases,
..Default::default()
};
let devices = vec![
"room1".to_string(),
"room2".to_string(),
"direct-address".to_string(),
];
let result = resolve_devices(devices, &config);
assert_eq!(result.len(), 3);
assert_eq!(result[0], "AA:BB:CC:DD:EE:FF");
assert_eq!(result[1], "11:22:33:44:55:66");
assert_eq!(result[2], "direct-address");
}
#[test]
fn test_resolve_devices_no_aliases() {
let config = Config::default();
let devices = vec!["device1".to_string(), "device2".to_string()];
let result = resolve_devices(devices, &config);
assert_eq!(result.len(), 2);
assert_eq!(result[0], "device1");
assert_eq!(result[1], "device2");
}
#[test]
fn test_get_device_source_explicit() {
let config = Config::default();
let (device, source) = get_device_source(Some("explicit-device"), &config);
assert_eq!(device, Some("explicit-device".to_string()));
assert_eq!(source, None); }
#[test]
fn test_get_device_source_from_default() {
let config = Config {
device: Some("default-device".to_string()),
..Default::default()
};
let (device, source) = get_device_source(None, &config);
assert_eq!(device, Some("default-device".to_string()));
assert_eq!(source, Some("default"));
}
#[test]
fn test_get_device_source_from_last() {
let config = Config {
last_device: Some("last-device".to_string()),
..Default::default()
};
let (device, source) = get_device_source(None, &config);
assert_eq!(device, Some("last-device".to_string()));
assert_eq!(source, Some("last"));
}
#[test]
fn test_get_device_source_prefers_default_over_last() {
let config = Config {
device: Some("default-device".to_string()),
last_device: Some("last-device".to_string()),
..Default::default()
};
let (device, source) = get_device_source(None, &config);
assert_eq!(device, Some("default-device".to_string()));
assert_eq!(source, Some("default"));
}
#[test]
fn test_get_device_source_resolves_alias() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("my-sensor".to_string(), "AA:BB:CC:DD:EE:FF".to_string());
let config = Config {
aliases,
..Default::default()
};
let (device, source) = get_device_source(Some("my-sensor"), &config);
assert_eq!(device, Some("AA:BB:CC:DD:EE:FF".to_string()));
assert_eq!(source, None);
}
}