use anyhow::{Context, Result, bail}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, path::{Path, PathBuf}, }; use crate::backend::Provider; use crate::file; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Config { pub keys: KeymapConfig, pub providers: ProvidersConfig, pub notifications: NotificationsConfig, pub max_write_lines: usize, /// How often (seconds) the TUI polls for new messages in the open chat. pub chat_poll_interval_secs: u64, /// How often (seconds) the Telegram client calls `sync_update_state`. /// This call saves chat data to the local Telegram DB pub sync_update_state_secs: u64, /// How often (seconds) the TUI polls for unread-count changes across all chats. pub chat_list_sync_secs: u64, } impl Default for Config { fn default() -> Self { Self { keys: KeymapConfig::default(), providers: ProvidersConfig::default(), notifications: NotificationsConfig::default(), max_write_lines: 5, chat_poll_interval_secs: 10, sync_update_state_secs: 120, chat_list_sync_secs: 10, } } } #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(default)] pub struct ProvidersConfig { pub telegram: TelegramConfig, pub whatsapp: bool, } #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(default)] pub struct TelegramConfig { pub enabled: bool, pub api_id: u32, pub api_hash: String, } impl TelegramConfig { pub fn has_credentials(&self) -> bool { self.api_id != 0 && !self.api_hash.is_empty() } } /// Notification preferences, read from the `[notifications]` table. /// /// Every switch is off and every key is optional by default, so a config file /// written before notifications existed keeps loading and keeps quiet. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct NotificationsConfig { pub os: bool, /// Play this messenger's sound file on a new message. pub sound: bool, /// How long one messenger coalesces a burst of new messages into a single /// notification. The window gates both channels at once, so a burst that /// plays one cue also raises one desktop notification. `0` notifies on /// every message. pub debounce_ms: u64, /// Sound file per messenger, keyed by [`Provider`]. A messenger with no /// entry plays no cue at all, though its desktop notifications are /// unaffected. The key is the only messenger-specific value in the config, /// so a new provider needs no new field here. pub sounds: HashMap, } impl Default for NotificationsConfig { fn default() -> Self { Self { os: false, sound: false, debounce_ms: 1500, sounds: HashMap::new(), } } } impl NotificationsConfig { /// The sound file configured for `provider`, or `None` when that messenger /// has no sound: the key is absent, or the path is blank. Every consumer goes /// through this so "no sound for this messenger" means one thing. pub fn sound_for(&self, provider: Provider) -> Option<&Path> { let configured = self.sounds.get(&provider)?; // A blank entry is how a messenger is muted without deleting its key, so // it means "no sound" exactly like an absent key does. let blank = configured .as_os_str() .to_str() .is_none_or(|raw| raw.trim().is_empty()); (!blank).then_some(configured.as_path()) } } #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct KeymapConfig { pub search_text: String, pub scroll_up: String, pub scroll_down: String, pub select: String, pub pane_prev: String, pub pane_next: String, pub dismiss: String, pub quit: String, pub open_settings: String, pub focus_write: String, pub scroll_to_top: String, pub scroll_to_bottom: String, pub send: String, pub newline: String, pub retry_connection: String, pub record_voice: String, pub play_pause: String, pub seek_back: String, pub seek_forward: String, } impl Default for KeymapConfig { fn default() -> Self { Self { scroll_up: "k".into(), scroll_to_top: "g".into(), search_text: "/".into(), scroll_down: "j".into(), select: "enter".into(), pane_prev: "shift+tab".into(), pane_next: "tab".into(), dismiss: "esc".into(), quit: "ctrl+c".into(), open_settings: "s".into(), focus_write: "i".into(), scroll_to_bottom: "G".into(), send: "enter".into(), newline: "shift+enter".into(), retry_connection: "r".into(), play_pause: "space".into(), seek_back: "<".into(), seek_forward: ">".into(), record_voice: "a".into(), } } } impl KeymapConfig { pub fn parse(&self) -> Result { Ok(Keymap { scroll_up: parse_key(&self.scroll_up)?, scroll_to_top: parse_key(&self.scroll_to_top)?, search_text: parse_key(&self.search_text)?, scroll_down: parse_key(&self.scroll_down)?, select: parse_key(&self.select)?, pane_prev: parse_key(&self.pane_prev)?, pane_next: parse_key(&self.pane_next)?, dismiss: parse_key(&self.dismiss)?, quit: parse_key(&self.quit)?, open_settings: parse_key(&self.open_settings)?, focus_write: parse_key(&self.focus_write)?, scroll_to_bottom: parse_key(&self.scroll_to_bottom)?, send: parse_key(&self.send)?, newline: parse_key(&self.newline)?, retry_connection: parse_key(&self.retry_connection)?, play_pause: parse_key(&self.play_pause)?, seek_back: parse_key(&self.seek_back)?, seek_forward: parse_key(&self.seek_forward)?, record_voice: parse_key(&self.record_voice)?, }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Keymap { pub scroll_up: KeyEvent, pub search_text: KeyEvent, pub scroll_down: KeyEvent, pub select: KeyEvent, pub pane_next: KeyEvent, pub pane_prev: KeyEvent, pub dismiss: KeyEvent, pub quit: KeyEvent, pub open_settings: KeyEvent, pub focus_write: KeyEvent, pub scroll_to_top: KeyEvent, pub scroll_to_bottom: KeyEvent, pub send: KeyEvent, /// Enter (with a modifier) also inserts a newline; this key is honored if the terminal /// reports it (e.g. Shift+Enter on kitty-protocol terminals). pub newline: KeyEvent, /// Manually force a reconnection after a provider entered the /// "connection lost" state. pub retry_connection: KeyEvent, /// Toggle play/pause on the audio popup. pub play_pause: KeyEvent, /// Seek backward on the audio popup. pub seek_back: KeyEvent, /// Seek forward on the audio popup. pub seek_forward: KeyEvent, /// Push-to-talk voice-note recording (press starts, release sends; a second /// press acts as stop-and-send on terminals without key-release events). pub record_voice: KeyEvent, } /// Tests must never read or write a real user's config. Under `#[cfg(test)]` /// every config path resolves to a single throwaway directory under the /// system temp dir instead of `$XDG_CONFIG_HOME/senders`. #[cfg(test)] static TEST_CONFIG_DIR: std::sync::OnceLock = std::sync::OnceLock::new(); /// Optional override for the config directory (e.g. for `--mock` mode). /// Must be set before any call to `user_config_dir()`; typically set once /// in `main()` before initializing the runtime. static CONFIG_DIR_OVERRIDE: std::sync::OnceLock = std::sync::OnceLock::new(); impl Config { /// Sets a custom config directory for the process. Returns `Err` if /// already set. Intended for one-time initialization in `main()`. pub fn set_config_dir_override(dir: PathBuf) -> Result<(), anyhow::Error> { CONFIG_DIR_OVERRIDE .set(dir) .map_err(|_| anyhow::anyhow!("config dir override already set")) } pub fn user_config_dir() -> Result { if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { return Ok(dir.clone()); } #[cfg(test)] { if let Some(dir) = TEST_CONFIG_DIR.get() { return Ok(dir.clone()); } let dir = std::env::temp_dir().join(format!("senders-test-{}", std::process::id())); let _ = file::create_dir_all(&dir); let _ = TEST_CONFIG_DIR.set(dir.clone()); Ok(dir) } #[cfg(not(test))] { dirs::config_dir() .or_else(dirs::home_dir) .map(|base| base.join("senders")) .ok_or_else(|| anyhow::anyhow!("could not determine a config directory")) } } pub fn config_file_path() -> Result { Ok(Self::user_config_dir()?.join("config.toml")) } pub fn exists() -> bool { Self::config_file_path() .map(|path| path.exists()) .unwrap_or(false) } pub fn load() -> Result> { let path = Self::config_file_path()?; if !path.exists() { return Ok(None); } let raw = file::read_to_string(&path)?; let config = toml::from_str(&raw) .with_context(|| format!("invalid config at {}", path.display()))?; Ok(Some(config)) } /// main func to save the app config file pub fn save_config(&self) -> Result<()> { let path = Self::config_file_path()?; let raw = toml::to_string_pretty(self)?; file::write_owner_only(&path, raw.as_bytes())?; Ok(()) } } fn parse_key(spec: &str) -> Result { let parts: Vec<&str> = spec.trim().split('+').map(str::trim).collect(); if parts.is_empty() || parts.iter().any(|p| p.is_empty()) { bail!("empty key spec '{spec}'"); } let mut ctrl = false; let mut alt = false; let mut shift = false; for modifier in &parts[..parts.len() - 1] { match *modifier { "ctrl" | "control" => ctrl = true, "alt" | "option" => alt = true, "shift" => shift = true, "super" | "meta" | "cmd" | "cmdorctrl" => {} other => bail!("unsupported modifier '{other}' in '{spec}'"), } } let base = parts[parts.len() - 1]; let code = match base { "enter" | "return" => KeyCode::Enter, "esc" | "escape" => KeyCode::Esc, "tab" => KeyCode::Tab, "backspace" => KeyCode::Backspace, "space" => KeyCode::Char(' '), "delete" => KeyCode::Delete, "insert" => KeyCode::Insert, "home" => KeyCode::Home, "end" => KeyCode::End, "up" => KeyCode::Up, "down" => KeyCode::Down, "left" => KeyCode::Left, "right" => KeyCode::Right, "pgup" | "pageup" => KeyCode::PageUp, "pgdn" | "pagedown" => KeyCode::PageDown, other if other.len() > 1 && other.starts_with('f') && other[1..].chars().all(|c| c.is_ascii_digit()) => { let number: u8 = other[1..].parse().expect("digits parse"); if !(1..=12).contains(&number) { bail!("unsupported key '{other}' in '{spec}'"); } KeyCode::F(number) } single if single.len() == 1 => { let c = single.chars().next().expect("single char"); if !c.is_ascii() { bail!("non-ascii key '{single}' in '{spec}'"); } if c.is_ascii_uppercase() { shift = true; } KeyCode::Char(c.to_ascii_lowercase()) } other => bail!("unsupported key '{other}' in '{spec}'"), }; let mut modifiers = KeyModifiers::NONE; if ctrl { modifiers |= KeyModifiers::CONTROL; } if alt { modifiers |= KeyModifiers::ALT; } if shift { modifiers |= KeyModifiers::SHIFT; } Ok(KeyEvent::new(code, modifiers)) } #[cfg(test)] mod tests { use super::*; #[test] fn defaults_parse_to_expected_keys() { let keymap = KeymapConfig::default().parse().unwrap(); assert_eq!( keymap.send, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE) ); assert_eq!( keymap.newline, KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT) ); assert_eq!( keymap.quit, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL) ); assert_eq!( keymap.dismiss, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE) ); assert_eq!( keymap.scroll_to_bottom, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::SHIFT) ); assert_eq!( keymap.retry_connection, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE) ); assert_eq!( keymap.play_pause, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE) ); assert_eq!( keymap.seek_back, KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE) ); assert_eq!( keymap.seek_forward, KeyEvent::new(KeyCode::Char('>'), KeyModifiers::NONE) ); assert_eq!( keymap.record_voice, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE) ); } #[test] fn uppercase_char_implies_shift() { let key = parse_key("J").unwrap(); assert_eq!(key.code, KeyCode::Char('j')); assert!(key.modifiers.contains(KeyModifiers::SHIFT)); } #[test] fn unknown_key_is_an_error() { assert!(parse_key("banana").is_err()); assert!(parse_key("ctrl+banana").is_err()); } #[test] fn config_round_trips_through_toml() { let mut config = Config::default(); config.providers.telegram.enabled = true; config.max_write_lines = 3; let raw = toml::to_string_pretty(&config).unwrap(); let back: Config = toml::from_str(&raw).unwrap(); assert!(back.providers.telegram.enabled); assert!(!back.providers.whatsapp); assert_eq!(back.keys.send, "enter"); assert_eq!(back.max_write_lines, 3); } #[test] fn missing_keys_fall_back_to_defaults() { let raw = "[providers]\nwhatsapp = true\n"; let config: Config = toml::from_str(raw).unwrap(); assert_eq!(config.keys.send, "enter"); assert!(!config.providers.telegram.enabled); assert!(config.providers.whatsapp); assert_eq!(config.max_write_lines, 5); } #[test] fn notifications_default_to_silent() { let config = Config::default(); assert!(!config.notifications.os); assert!(!config.notifications.sound); assert_eq!(config.notifications.debounce_ms, 1500); assert!(config.notifications.sounds.is_empty()); assert_eq!(config.notifications.sound_for(Provider::Telegram), None); assert_eq!(config.notifications.sound_for(Provider::WhatsApp), None); } #[test] fn config_without_notifications_stays_silent() { // A config file written before notifications existed must keep loading // with every notification switch off, never with a surprise sound. let raw = "max_write_lines = 5\n[providers]\nwhatsapp = true\n"; let config: Config = toml::from_str(raw).unwrap(); assert!(!config.notifications.os); assert!(!config.notifications.sound); assert_eq!(config.notifications.debounce_ms, 1500); assert!(config.notifications.sound_for(Provider::Telegram).is_none()); } #[test] fn notifications_round_trip_through_toml() { let mut config = Config::default(); config.notifications.os = true; config.notifications.sound = true; config.notifications.debounce_ms = 0; config .notifications .sounds .insert(Provider::Telegram, PathBuf::from("/home/you/sounds/tg.wav")); // A relative path and a blank entry are both values a user can type. config .notifications .sounds .insert(Provider::WhatsApp, PathBuf::from("")); let raw = toml::to_string_pretty(&config).unwrap(); let back: Config = toml::from_str(&raw).unwrap(); assert!(back.notifications.os); assert!(back.notifications.sound); assert_eq!(back.notifications.debounce_ms, 0); assert_eq!( back.notifications.sound_for(Provider::Telegram), Some(Path::new("/home/you/sounds/tg.wav")) ); assert_eq!(back.notifications.sound_for(Provider::WhatsApp), None); assert_eq!(config.notifications.sounds, back.notifications.sounds); } #[test] fn notification_sounds_parse_from_a_hand_written_table() { // Key spelling is the user-facing contract: lowercase provider names. let raw = concat!( "[notifications]\n", "os = true\n", "sound = true\n", "debounce_ms = 250\n", "\n[notifications.sounds]\n", "telegram = \"sounds/tg.wav\"\n", "whatsapp = \"/home/you/sounds/wa.flac\"\n", ); let config: Config = toml::from_str(raw).unwrap(); assert!(config.notifications.os); assert!(config.notifications.sound); assert_eq!(config.notifications.debounce_ms, 250); assert_eq!( config.notifications.sound_for(Provider::Telegram), Some(Path::new("sounds/tg.wav")) ); assert_eq!( config.notifications.sound_for(Provider::WhatsApp), Some(Path::new("/home/you/sounds/wa.flac")) ); } #[test] fn unknown_messenger_sound_key_is_rejected() { // A typo must not silently read as "this messenger has no sound". let raw = "[notifications.sounds]\ntelegarm = \"tg.wav\"\n"; assert!(toml::from_str::(raw).is_err()); } #[test] fn sound_for_treats_a_blank_path_as_no_sound() { let mut config = Config::default(); for blank in ["", " ", "\t"] { config .notifications .sounds .insert(Provider::Telegram, PathBuf::from(blank)); assert_eq!(config.notifications.sound_for(Provider::Telegram), None); } config.notifications.sounds.clear(); assert_eq!(config.notifications.sound_for(Provider::Telegram), None); config .notifications .sounds .insert(Provider::Telegram, PathBuf::from("sounds/tg.wav")); assert_eq!( config.notifications.sound_for(Provider::Telegram), Some(Path::new("sounds/tg.wav")) ); // Configuring one messenger never configures the other. assert_eq!(config.notifications.sound_for(Provider::WhatsApp), None); } #[test] fn partial_keys_fall_back_for_new_actions() { // A config written before `retry_connection` existed must still load // and pick up the default binding for the new action. let raw = "[keys]\nsearch_text = \"s\"\n"; let config: Config = toml::from_str(raw).unwrap(); assert_eq!(config.keys.search_text, "s"); assert_eq!(config.keys.retry_connection, "r"); assert_eq!(config.keys.play_pause, "space"); assert_eq!(config.keys.seek_back, "<"); assert_eq!(config.keys.seek_forward, ">"); assert_eq!(config.keys.record_voice, "a"); } #[test] fn test_config_io_uses_a_temp_dir() { // Under `#[cfg(test)]` every config path must resolve to a throwaway // temp directory, never a real user's config dir. let dir = Config::user_config_dir().unwrap(); assert_eq!( dir.parent(), Some(std::env::temp_dir().as_path()), "test config dir must live in the system temp dir, got: {}", dir.display() ); assert_eq!( Config::config_file_path().unwrap().parent(), Some(dir.as_path()) ); // And saves/loads round-trip through that temp dir (no real config // file was touched to write either of these). let reported = Config::config_file_path().unwrap(); let cfg = Config { max_write_lines: 3, ..Config::default() }; cfg.save_config().unwrap(); let loaded = Config::load().unwrap().unwrap(); assert_eq!(loaded.max_write_lines, 3); assert_eq!(reported, Config::config_file_path().unwrap()); } }