use serde::{Deserialize, Serialize}; use anyhow::Result; use std::fs; use std::path::PathBuf; #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Endpoint { pub name: String, pub url: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub private_key: Option, } impl Endpoint { /// Creates a new endpoint with a freshly generated Ed25519 key pair. pub fn new(name: String, url: String) -> Self { Self { name, url, private_key: Some(crate::identity::Identity::generate().to_b64()), } } pub fn identity(&self) -> Option { self.private_key.as_deref() .and_then(|k| crate::identity::Identity::from_b64(k).ok()) } } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Config { // Legacy field — present only in old configs; migrated to endpoints on load. #[serde(default, skip_serializing)] server_url: Option, #[serde(default)] pub endpoints: Vec, #[serde(default)] pub active_endpoint: String, pub timeout_sec: u64, #[serde(default = "default_theme")] pub theme: Theme, #[serde(default)] pub update_api: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Theme { #[serde(default = "default_selected_bg")] pub selected_bg: String, } fn default_selected_bg() -> String { "blue".to_string() } fn default_theme() -> Theme { Theme { selected_bg: default_selected_bg() } } pub fn config_path() -> PathBuf { let base = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) .unwrap_or_else(|_| { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".config") }); base.join(env!("CARGO_PKG_NAME")).join("config.toml") } impl Config { pub fn exists() -> bool { config_path().exists() } pub fn active_url(&self) -> &str { &self.active_endpoint } /// Returns the identity for the currently active endpoint, if any. pub fn active_identity(&self) -> Option { self.endpoints.iter() .find(|ep| ep.url == self.active_endpoint) .and_then(|ep| ep.identity()) } pub fn load() -> Result { let path = config_path(); let content = fs::read_to_string(&path)?; let mut cfg: Config = toml::from_str(&content)?; // Migrate old single server_url format to endpoints list. if cfg.endpoints.is_empty() { if let Some(url) = cfg.server_url.take() { cfg.active_endpoint = url.clone(); cfg.endpoints.push(Endpoint { name: "Default".to_string(), url, private_key: None }); } } if cfg.active_endpoint.is_empty() { if let Some(ep) = cfg.endpoints.first() { cfg.active_endpoint = ep.url.clone(); } } Ok(cfg) } pub fn create_with_endpoint(url: &str, name: &str) -> Result { let path = config_path(); let config = Config { server_url: None, endpoints: vec![Endpoint::new(name.to_string(), url.to_string())], active_endpoint: url.to_string(), timeout_sec: 10, theme: default_theme(), update_api: Some("https://git.vainend.com/api/v1/repos/admin/ostiary".to_string()), }; fs::create_dir_all(path.parent().unwrap())?; fs::write(&path, config.to_toml())?; Ok(config) } pub fn save(&self) -> Result<()> { let path = config_path(); fs::create_dir_all(path.parent().unwrap())?; fs::write(&path, self.to_toml())?; Ok(()) } fn to_toml(&self) -> String { let mut s = format!( "active_endpoint = \"{}\"\ntimeout_sec = {}\n", escape_toml(&self.active_endpoint), self.timeout_sec, ); if let Some(api) = &self.update_api { s.push_str(&format!("update_api = \"{}\"\n", escape_toml(api))); } s.push_str(&format!( "\n[theme]\nselected_bg = \"{}\"\n", escape_toml(&self.theme.selected_bg), )); for ep in &self.endpoints { s.push_str(&format!( "\n[[endpoints]]\nname = \"{}\"\nurl = \"{}\"\n", escape_toml(&ep.name), escape_toml(&ep.url), )); if let Some(key) = &ep.private_key { s.push_str(&format!("private_key = \"{}\"\n", escape_toml(key))); } } s } } fn escape_toml(s: &str) -> String { s.replace('\\', "\\\\").replace('"', "\\\"") }