use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AppConfig { pub host_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, } impl Default for AppConfig { fn default() -> Self { Self { host_id: Uuid::new_v4().to_string(), display_name: None, } } } /// Resolves configuration path according to platform conventions: /// - Windows: `%APPDATA%\HermesPair\config.json` /// - Linux/Unix: `~/.config/hermes-pair/config.json` pub fn get_config_path() -> PathBuf { #[cfg(target_os = "windows")] { if let Ok(appdata) = std::env::var("APPDATA") { return PathBuf::from(appdata) .join("HermesPair") .join("config.json"); } if let Some(config_dir) = dirs::config_dir() { return config_dir.join("HermesPair").join("config.json"); } PathBuf::from("config.json") } #[cfg(not(target_os = "windows"))] { if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { return PathBuf::from(xdg).join("hermes-pair").join("config.json"); } if let Some(config_dir) = dirs::config_dir() { return config_dir.join("hermes-pair").join("config.json"); } if let Some(home_dir) = dirs::home_dir() { return home_dir .join(".config") .join("hermes-pair") .join("config.json"); } PathBuf::from("config.json") } } /// Saves the configuration atomically by writing to a temporary file and renaming it. pub fn save_config_to_path(config: &AppConfig, path: &Path) -> Result<(), std::io::Error> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let json_bytes = serde_json::to_vec_pretty(config) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; let tmp_file_name = format!( "{}.tmp.{}", path.file_name() .and_then(|n| n.to_str()) .unwrap_or("config"), Uuid::new_v4() ); let tmp_path = match path.parent() { Some(p) => p.join(tmp_file_name), None => PathBuf::from(tmp_file_name), }; fs::write(&tmp_path, json_bytes)?; // On Windows and Unix, fs::rename replaces the destination atomically if in the same directory. if let Err(_err) = fs::rename(&tmp_path, path) { // Fallback in case rename fails due to cross-platform replacement edge cases let _ = fs::remove_file(path); if let Err(fallback_err) = fs::rename(&tmp_path, path) { let _ = fs::remove_file(&tmp_path); return Err(fallback_err); } } Ok(()) } /// Loads an existing config or generates a new one with a persistent UUIDv4 `host_id` and saves it. pub fn load_or_create_config_from_path(path: &Path) -> Result { if path.exists() { let content = fs::read_to_string(path)?; if let Ok(mut config) = serde_json::from_str::(&content) { if config.host_id.trim().is_empty() { config.host_id = Uuid::new_v4().to_string(); save_config_to_path(&config, path)?; } return Ok(config); } } let new_config = AppConfig::default(); save_config_to_path(&new_config, path)?; Ok(new_config) } /// Convenience function to load or create configuration at the default system location. pub fn load_or_create_config() -> Result { let path = get_config_path(); load_or_create_config_from_path(&path) }