use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const DEFAULT_WS_URL: &str = "ws://localhost:4455";
const CONFIG_DIR_NAME: &str = "obs-hotkey";
const CONFIG_FILE_NAME: &str = "hotkeys.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct HotkeyConfig {
pub toggle_recording: String,
pub toggle_pause: String,
pub toggle_streaming: String,
pub screenshot: String,
pub toggle_mute_mic: String,
pub toggle_studio_mode: String,
pub toggle_replay_buffer: String,
pub save_replay: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(rename = "obs_host")]
pub obs_host: String,
pub hotkeys: HotkeyConfig,
#[serde(rename = "screenshot_source")]
pub screenshot_source: String,
#[serde(rename = "screenshot_dir")]
pub screenshot_dir: String,
#[serde(rename = "mic_name")]
pub mic_name: String,
}
pub fn default_config() -> AppConfig {
AppConfig {
obs_host: DEFAULT_WS_URL.to_string(),
hotkeys: HotkeyConfig {
toggle_recording: "scroll lock".to_string(),
toggle_pause: "pause".to_string(),
toggle_streaming: String::new(),
screenshot: String::new(),
toggle_mute_mic: String::new(),
toggle_studio_mode: String::new(),
toggle_replay_buffer: String::new(),
save_replay: String::new(),
},
screenshot_source: String::new(),
screenshot_dir: "~/Pictures".to_string(),
mic_name: String::new(),
}
}
pub fn real_home() -> PathBuf {
if let Some(sudo_user) = std::env::var_os("SUDO_USER") {
let passwd = fs::read_to_string("/etc/passwd").ok();
if let Some(pw) = passwd {
for line in pw.lines() {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 6 && parts[0] == sudo_user.to_str().unwrap_or("") {
return PathBuf::from(parts[5]);
}
}
}
}
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
}
pub fn config_path() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(xdg)
.join(CONFIG_DIR_NAME)
.join(CONFIG_FILE_NAME);
}
let home = real_home();
home.join(".config").join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME)
}
pub fn expand_home(path: &str) -> String {
if let Some(stripped) = path.strip_prefix('~') {
format!("{}{}", real_home().display(), stripped)
} else {
path.to_string()
}
}
pub fn sanitize_obs_host(host: &str) -> String {
if host.is_empty() {
return host.to_string();
}
if host.starts_with("ws://") || host.starts_with("wss://") {
host.to_string()
} else {
format!("ws://{}", host)
}
}
pub fn load_config(path: &Path) -> anyhow::Result<AppConfig> {
let data = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("failed to read config: {}", e))?;
let mut cfg: AppConfig = serde_json::from_str(&data)
.map_err(|e| anyhow::anyhow!("failed to parse config: {}", e))?;
cfg.obs_host = sanitize_obs_host(&cfg.obs_host);
cfg.screenshot_dir = expand_home(&cfg.screenshot_dir);
Ok(cfg)
}
pub fn ensure_config(dir_path: &Path, file_path: &Path) -> anyhow::Result<()> {
if file_path.exists() {
return Ok(());
}
fs::create_dir_all(dir_path)
.map_err(|e| anyhow::anyhow!("failed to create config directory: {}", e))?;
let cfg = default_config();
let data = serde_json::to_string_pretty(&cfg)
.map_err(|e| anyhow::anyhow!("failed to marshal default config: {}", e))?;
let mut file = fs::File::create(file_path)
.map_err(|e| anyhow::anyhow!("failed to create config file: {}", e))?;
file.write_all(data.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write config file: {}", e))?;
log::info!("Created default config at: {}", file_path.display());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let cfg = default_config();
assert_eq!(cfg.obs_host, "ws://localhost:4455");
assert_eq!(cfg.hotkeys.toggle_recording, "scroll lock");
assert_eq!(cfg.hotkeys.toggle_pause, "pause");
}
#[test]
fn test_sanitize_obs_host() {
assert_eq!(sanitize_obs_host("localhost:4455"), "ws://localhost:4455");
assert_eq!(sanitize_obs_host("ws://localhost:4455"), "ws://localhost:4455");
assert_eq!(sanitize_obs_host("wss://localhost:4455"), "wss://localhost:4455");
assert_eq!(sanitize_obs_host(""), "");
}
#[test]
fn test_expand_home() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_home("~/Pictures"), format!("{}/Pictures", home.display()));
assert_eq!(expand_home("/tmp/abs"), "/tmp/abs");
}
#[test]
fn test_load_config_missing() {
let result = load_config(Path::new("/nonexistent/path/config.json"));
assert!(result.is_err());
}
#[test]
fn test_load_config_valid() {
let temp = std::env::temp_dir();
let path = temp.join("hotkeys.json");
fs::write(&path, r#"{"obs_host":"ws://localhost:4455","hotkeys":{"toggle_recording":"f1","toggle_pause":"f2","toggle_streaming":"","screenshot":"","toggle_mute_mic":"","toggle_studio_mode":"","toggle_replay_buffer":"","save_replay":""},"screenshot_source":"","screenshot_dir":"~/Pictures","mic_name":""}"#).unwrap();
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.obs_host, "ws://localhost:4455");
assert_eq!(cfg.hotkeys.toggle_recording, "f1");
assert_eq!(cfg.hotkeys.toggle_pause, "f2");
fs::remove_file(&path).ok();
}
#[test]
fn test_load_config_bare_host() {
let temp = std::env::temp_dir();
let path = temp.join("hotkeys2.json");
fs::write(&path, r#"{"obs_host":"localhost:4455","hotkeys":{"toggle_recording":"","toggle_pause":"","toggle_streaming":"","screenshot":"","toggle_mute_mic":"","toggle_studio_mode":"","toggle_replay_buffer":"","save_replay":""},"screenshot_source":"","screenshot_dir":"","mic_name":""}"#).unwrap();
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.obs_host, "ws://localhost:4455");
fs::remove_file(&path).ok();
}
#[test]
fn test_ensure_config_creates_default() {
let temp = std::env::temp_dir().join("obs-hotkey-test");
let dir = temp.join(".config").join("obs-hotkey");
let path = dir.join("hotkeys.json");
ensure_config(&dir, &path).unwrap();
assert!(path.exists());
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.hotkeys.toggle_recording, "scroll lock");
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn test_ensure_config_does_not_overwrite() {
let temp = std::env::temp_dir().join("obs-hotkey-test2");
let dir = temp.join(".config").join("obs-hotkey");
let path = dir.join("hotkeys.json");
fs::create_dir_all(&dir).unwrap();
fs::write(&path, r#"{"obs_host":"ws://custom:1234","hotkeys":{"toggle_recording":"scroll lock","toggle_pause":"","toggle_streaming":"","screenshot":"","toggle_mute_mic":"","toggle_studio_mode":"","toggle_replay_buffer":"","save_replay":""},"screenshot_source":"","screenshot_dir":"","mic_name":""}"#).unwrap();
ensure_config(&dir, &path).unwrap();
let cfg = load_config(&path).unwrap();
assert_eq!(cfg.obs_host, "ws://custom:1234");
std::fs::remove_dir_all(&temp).ok();
}
}