//! Configuration file + theme system — Phase 18. //! //! Loads/saves configuration from `~/.nirc/config.toml`. //! Supports per-protocol server presets, theme definitions, and notification settings. //! defaults to sensible defaults if no config file exists. //! //! ## Matrix server entries (0.2.0) //! //! Matrix servers are configured as `[[servers]]` entries with `protocol = "matrix"`. //! The `address` field is the homeserver URL (e.g. `https://matrix.org`). //! Matrix-specific settings go in `[servers.extra]`: //! //! ```toml //! [[servers]] //! name = "matrix" //! protocol = "matrix" //! address = "https://matrix.org" //! auto_join = ["#nirc:matrix.org"] //! //! [servers.extra] //! user_id = "@alice:matrix.org" # required //! password = "hunter2" # for password login //! device_id = "NIRC-DEVICE-1" # optional //! device_name = "nirc-rs" # optional, defaults to "nirc-rs" //! access_token = "syt_abc..." # optional, for resume without password //! sso = "false" # SSO not yet supported in 0.2.0 //! e2ee_passphrase = "vault-passphrase" # optional, defaults to "nirc-rs-default-passphrase" //! ``` //! //! Matrix-specific connection parameters are pulled from the `extra` map at //! connect time by [`matrix_config_from_entry`]. //! //! ## BitChat P2P server entries (0.5.0) //! //! BitChat servers are configured as `[[servers]]` entries with `protocol = "bitchat"`. //! The `address` field is the listen multiaddr (e.g. `/ip4/0.0.0.0/tcp/9394`). //! Optional bootstrap node in `[servers.extra]`: //! //! ```toml //! [[servers]] //! name = "bitchat" //! protocol = "bitchat" //! address = "/ip4/0.0.0.0/tcp/9394" //! //! [servers.extra] //! bootstrap = "/ip4/1.2.3.4/tcp/9394/p2p/QmSomePeerId" //! ``` use crate::core::protocol::ProtocolType; use crate::tui::foundation::Theme; use ratatui::prelude::Color; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use tracing::{debug, info, warn}; /// Top-level nirc-rs configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NaimConfig { /// Global settings. #[serde(default)] pub global: GlobalConfig, /// Per-protocol server connection presets. #[serde(default)] pub servers: Vec, /// TUI appearance. #[serde(default)] pub appearance: AppearanceConfig, /// Notification settings. #[serde(default)] pub notifications: NotifyConfigEntry, /// File transfer settings. #[serde(default)] pub transfers: TransferConfig, /// Custom keybindings (key name → command). #[serde(default)] pub keybindings: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GlobalConfig { /// Default nickname. #[serde(default = "default_nick")] pub nickname: String, /// Default real name. #[serde(default = "default_realname")] pub realname: String, /// Log level for tracing. #[serde(default = "default_log_level")] pub log_level: String, /// Auto-connect to servers on startup. #[serde(default)] pub auto_connect: Vec, } impl Default for GlobalConfig { fn default() -> Self { Self { nickname: default_nick(), realname: default_realname(), log_level: default_log_level(), auto_connect: Vec::new() } } } fn default_nick() -> String { "nirc".into() } fn default_realname() -> String { "nirc-rs user".into() } fn default_log_level() -> String { "warn".into() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerEntry { /// Human-readable label. pub name: String, /// Protocol type. pub protocol: ProtocolType, /// Server address (host:port or URL). pub address: String, /// Nickname override (None = use global default). pub nickname: Option, /// Password. pub password: Option, /// Auto-join channels/listen address. #[serde(default)] pub auto_join: Vec, /// TLS enabled. #[serde(default)] pub tls: bool, /// If true (default), automatically reconnect on disconnect with /// exponential backoff. Per-protocol override of the global default. #[serde(default = "default_true")] pub auto_reconnect: bool, /// Extra protocol-specific fields. #[serde(default)] pub extra: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppearanceConfig { /// Theme name (built-in: "default", "solarized", "gruvbox", "dracula"). #[serde(default = "default_theme_name")] pub theme: String, /// Custom theme overrides. #[serde(default)] pub custom_colors: HashMap, /// Show timestamps in chat. #[serde(default = "default_true")] pub show_timestamps: bool, /// 24-hour clock. #[serde(default = "default_true")] pub clock_24h: bool, /// Maximum scrollback messages per tab. #[serde(default = "default_scrollback")] pub max_scrollback: usize, } impl Default for AppearanceConfig { fn default() -> Self { Self { theme: default_theme_name(), custom_colors: HashMap::new(), show_timestamps: true, clock_24h: true, max_scrollback: default_scrollback() } } } fn default_theme_name() -> String { "default".into() } fn default_true() -> bool { true } fn default_scrollback() -> usize { 5000 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotifyConfigEntry { #[serde(default = "default_true")] pub desktop_enabled: bool, #[serde(default = "default_true")] pub bell_enabled: bool, #[serde(default = "default_debounce")] pub debounce_ms: u64, #[serde(default)] pub extra_highlight_words: Vec, } impl Default for NotifyConfigEntry { fn default() -> Self { Self { desktop_enabled: true, bell_enabled: true, debounce_ms: default_debounce(), extra_highlight_words: Vec::new() } } } fn default_debounce() -> u64 { 2000 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransferConfig { /// Directory to save received files. #[serde(default = "default_download_dir")] pub download_dir: String, /// I/O buffer size for transfers (bytes). #[serde(default = "default_buffer_size")] pub buffer_size: usize, /// Maximum concurrent transfers. #[serde(default = "default_max_transfers")] pub max_concurrent: usize, /// Auto-accept files from trusted peers. #[serde(default)] pub auto_accept_from: Vec, } impl Default for TransferConfig { fn default() -> Self { Self { download_dir: default_download_dir(), buffer_size: default_buffer_size(), max_concurrent: default_max_transfers(), auto_accept_from: Vec::new() } } } fn default_download_dir() -> String { dirs::download_dir().map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "./downloads".into()) } fn default_buffer_size() -> usize { 256 * 1024 } fn default_max_transfers() -> usize { 3 } // ─── Config loading/saving ─────────────────────────────────────────────────── fn config_dir() -> PathBuf { dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")).join("nirc") } pub fn config_path() -> PathBuf { config_dir().join("config.toml") } /// Return the modification time of the config file, if it exists. pub fn config_mtime() -> Option { std::fs::metadata(config_path()).ok()?.modified().ok() } /// Load configuration from disk, defaulting to defaults. pub fn load_config() -> NaimConfig { let path = config_path(); if !path.exists() { info!("No config file found at {}, using defaults", path.display()); return NaimConfig::default(); } match std::fs::read_to_string(&path) { Ok(content) => match toml::from_str(&content) { Ok(config) => { info!("Loaded config from {}", path.display()); config } Err(e) => { warn!(%e, "Config parse error, using defaults"); NaimConfig::default() } }, Err(e) => { warn!(%e, "Config read error, using defaults"); NaimConfig::default() } } } /// Save configuration to disk. /// /// Uses a hard-link + rename strategy for atomicity: /// 1. Write the new config to a temp file in the same directory. /// 2. Create a hard link from the temp file to the target path. /// On POSIX filesystems, `hard_link` is atomic when src and dst are /// on the same filesystem — the target inode either has the old or /// new content, never a partial write. /// 3. Remove the temp file (the hard link keeps the data alive). /// /// defaults to the simpler tmp-rename approach if `hard_link` fails /// (e.g. cross-filesystem, permissions). The tmp-rename is still safe /// on most platforms — `rename(2)` is atomic on POSIX for same-dir renames. pub fn save_config(config: &NaimConfig) -> anyhow::Result<()> { let path = config_path(); std::fs::create_dir_all(config_dir())?; let content = toml::to_string_pretty(config)?; let tmp = path.with_extension("toml.tmp"); std::fs::write(&tmp, &content)?; // Try the atomic hard-link approach first. if path.exists() { match std::fs::hard_link(&tmp, &path) { Ok(()) => { // Hard link created atomically. Remove the temp file. let _ = std::fs::remove_file(&tmp); info!("Config saved to {} (atomic hard-link)", path.display()); return Ok(()); } Err(e) => { debug!(%e, "hard_link failed, defaulting to rename"); } } } // Fallback: rename (also atomic on POSIX for same-directory). std::fs::rename(&tmp, &path)?; info!("Config saved to {}", path.display()); Ok(()) } impl Default for NaimConfig { fn default() -> Self { Self { global: GlobalConfig::default(), servers: Vec::new(), appearance: AppearanceConfig::default(), notifications: NotifyConfigEntry::default(), transfers: TransferConfig::default(), keybindings: HashMap::new() } } } // ─── Built-in themes ───────────────────────────────────────────────────────── /// Resolve a theme name to a `Theme` struct. pub fn resolve_theme(name: &str, custom_overrides: &HashMap) -> Theme { let mut theme = match name { "solarized" => Theme { bg: Color::Rgb(0x00, 0x2B, 0x36), fg: Color::Rgb(0x83, 0x94, 0x96), accent: Color::Rgb(0x26, 0x8B, 0xD2), dim_fg: Color::Rgb(0x58, 0x6E, 0x75), error_fg: Color::Rgb(0xDC, 0x32, 0x2F), highlight_bg: Color::Rgb(0x07, 0x36, 0x42), tab_active_fg: Color::Rgb(0xFD, 0xF6, 0xE3), tab_active_bg: Color::Rgb(0x58, 0x6E, 0x75), tab_inactive_fg: Color::Rgb(0x58, 0x6E, 0x75), input_bg: Color::Rgb(0x00, 0x2B, 0x36), input_border: Color::Rgb(0x26, 0x8B, 0xD2), status_bg: Color::Rgb(0x07, 0x36, 0x42), status_fg: Color::Rgb(0x93, 0xA1, 0xA1), notice_fg: Color::Rgb(0xB5, 0x89, 0x00), own_msg_fg: Color::Rgb(0x85, 0x99, 0x00), action_fg: Color::Rgb(0xD3, 0x36, 0x82), }, "gruvbox" => Theme { bg: Color::Rgb(0x28, 0x28, 0x28), fg: Color::Rgb(0xEB, 0xDB, 0xB2), accent: Color::Rgb(0x83, 0xA5, 0x98), dim_fg: Color::Rgb(0x6C, 0x6C, 0x6C), error_fg: Color::Rgb(0xFB, 0x49, 0x34), highlight_bg: Color::Rgb(0x3C, 0x38, 0x36), tab_active_fg: Color::Rgb(0xEB, 0xDB, 0xB2), tab_active_bg: Color::Rgb(0x50, 0x49, 0x45), tab_inactive_fg: Color::Rgb(0x66, 0x5C, 0x54), input_bg: Color::Rgb(0x1D, 0x20, 0x21), input_border: Color::Rgb(0x83, 0xA5, 0x98), status_bg: Color::Rgb(0x3C, 0x38, 0x36), status_fg: Color::Rgb(0xEB, 0xDB, 0xB2), notice_fg: Color::Rgb(0xFA, 0xBD, 0x2F), own_msg_fg: Color::Rgb(0xB8, 0xBB, 0x26), action_fg: Color::Rgb(0xD3, 0x86, 0x9B), }, "dracula" => Theme { bg: Color::Rgb(0x28, 0x2A, 0x36), fg: Color::Rgb(0xF8, 0xF8, 0xF2), accent: Color::Rgb(0x6C, 0x70, 0x86), dim_fg: Color::Rgb(0x62, 0x72, 0xA4), error_fg: Color::Rgb(0xFF, 0x55, 0x55), highlight_bg: Color::Rgb(0x44, 0x47, 0x5A), tab_active_fg: Color::Rgb(0xFF, 0x79, 0xC6), tab_active_bg: Color::Rgb(0x44, 0x47, 0x5A), tab_inactive_fg: Color::Rgb(0x62, 0x72, 0xA4), input_bg: Color::Rgb(0x1E, 0x1F, 0x29), input_border: Color::Rgb(0xBD, 0x93, 0xF9), status_bg: Color::Rgb(0x44, 0x47, 0x5A), status_fg: Color::Rgb(0xF8, 0xF8, 0xF2), notice_fg: Color::Rgb(0xF1, 0xFA, 0x8C), own_msg_fg: Color::Rgb(0x50, 0xFA, 0x7B), action_fg: Color::Rgb(0xFF, 0x79, 0xC6), }, _ => Theme::default(), // "default" or unknown }; // Apply custom color overrides. for (key, value) in custom_overrides { if let Ok(color) = parse_color(value) { if key == "bg" { theme.bg = color; } else if key == "fg" { theme.fg = color; } else if key == "accent" { theme.accent = color; } else if key == "error_fg" { theme.error_fg = color; } else if key == "notice_fg" { theme.notice_fg = color; } else if key == "own_msg_fg" { theme.own_msg_fg = color; } else if key == "action_fg" { theme.action_fg = color; } else if key == "tab_active_bg" { theme.tab_active_bg = color; } else if key == "status_bg" { theme.status_bg = color; } else { debug!("Unknown theme key: {key}"); } } } theme } /// Parse a color string: hex "#RRGGBB", "rgb(r,g,b)", or named color. fn parse_color(s: &str) -> anyhow::Result { let s = s.trim(); if let Some(hex) = s.strip_prefix('#') { if hex.len() == 6 { let r = u8::from_str_radix(&hex[0..2], 16)?; let g = u8::from_str_radix(&hex[2..4], 16)?; let b = u8::from_str_radix(&hex[4..6], 16)?; return Ok(Color::Rgb(r, g, b)); } } if let Some(rest) = s.strip_prefix("rgb(").and_then(|r| r.strip_suffix(')')) { let parts: Vec<&str> = rest.split(',').collect(); if parts.len() == 3 { let r = parts[0].trim().parse::()?; let g = parts[1].trim().parse::()?; let b = parts[2].trim().parse::()?; return Ok(Color::Rgb(r, g, b)); } } // Named terminal colors. match s.to_lowercase().as_str() { "black" => Ok(Color::Black), "red" => Ok(Color::Red), "green" => Ok(Color::Green), "yellow" => Ok(Color::Yellow), "blue" => Ok(Color::Blue), "magenta" => Ok(Color::Magenta), "cyan" => Ok(Color::Cyan), "white" => Ok(Color::White), "darkgray" | "darkgrey" => Ok(Color::DarkGray), "gray" | "grey" => Ok(Color::Gray), "lightred" => Ok(Color::LightRed), "lightgreen" => Ok(Color::LightGreen), "lightyellow" => Ok(Color::LightYellow), "lightblue" => Ok(Color::LightBlue), "lightmagenta" => Ok(Color::LightMagenta), "lightcyan" => Ok(Color::LightCyan), "lightgray" | "lightgrey" => Ok(Color::Indexed(252)), "reset" => Ok(Color::Reset), _ => anyhow::bail!("unknown color: {s}"), } } // ─── Matrix helpers ───────────────────────────────────────────────────────── /// Helper to extract Matrix connection parameters from a `ServerEntry`'s `extra` map. /// Used by the dispatcher to construct `MatrixConfig`. /// /// Required keys (in `extra`): `user_id`. Recommended: `password`. Optional: /// `device_id`, `device_name` (defaults to "nirc-rs"), `access_token`, `sso`, /// `e2ee_passphrase`. Missing `user_id` is synthesized from the nickname and /// the host portion of `address` (e.g. `@nirc:matrix.org`). pub fn matrix_config_from_entry( entry: &ServerEntry, nickname: &str, msg_tx: &tokio::sync::mpsc::Sender, ) -> crate::protocols::matrix::MatrixConfig { use crate::protocols::matrix::MatrixConfig; let user_id = entry .extra .get("user_id") .cloned() .unwrap_or_else(|| { format!( "@{}:{}", nickname, entry .address .trim_start_matches("https://") .trim_start_matches("http://") ) }); let password = entry.extra.get("password").cloned().unwrap_or_default(); let device_id = entry.extra.get("device_id").cloned(); let device_name = entry .extra .get("device_name") .cloned() .or_else(|| Some("nirc-rs".to_owned())); let access_token = entry.extra.get("access_token").cloned(); let sso = entry .extra .get("sso") .map(|s| s == "true" || s == "1") .unwrap_or(false); let e2ee_passphrase = entry.extra.get("e2ee_passphrase").cloned(); let data_dir = dirs::data_dir().map(|d| d.join("nirc").join("matrix")); MatrixConfig { homeserver: entry.address.clone(), user_id, password, device_id, device_name, tx: msg_tx.clone(), access_token, sso, e2ee_passphrase, data_dir, } } #[cfg(test)] mod tests { use super::*; #[test] fn default_config_roundtrip() { let config = NaimConfig::default(); let toml_str = toml::to_string(&config).unwrap(); let parsed: NaimConfig = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.global.nickname, "nirc"); assert_eq!(parsed.appearance.theme, "default"); } #[test] fn resolve_default_theme() { let theme = resolve_theme("default", &HashMap::new()); assert_eq!(theme.accent, Color::Cyan); } #[test] fn resolve_dracula_theme() { let theme = resolve_theme("dracula", &HashMap::new()); assert_eq!(theme.bg, Color::Rgb(0x28, 0x2A, 0x36)); } #[test] fn custom_color_override() { let mut overrides = HashMap::new(); overrides.insert("accent".into(), "#FF00FF".into()); let theme = resolve_theme("default", &overrides); assert_eq!(theme.accent, Color::Rgb(0xFF, 0x00, 0xFF)); } #[test] fn parse_color_hex() { assert!(parse_color("#DEADBEEF").is_err()); assert_eq!(parse_color("#FF0000").unwrap(), Color::Rgb(0xFF, 0x00, 0x00)); } #[test] fn parse_color_rgb() { assert_eq!(parse_color("rgb(128,64,255)").unwrap(), Color::Rgb(128, 64, 255)); } #[test] fn parse_color_named() { assert_eq!(parse_color("red").unwrap(), Color::Red); } #[test] fn parse_color_lightgray() { assert_eq!(parse_color("lightgray").unwrap(), Color::Indexed(252)); assert_eq!(parse_color("lightgrey").unwrap(), Color::Indexed(252)); assert_ne!(parse_color("lightgray").unwrap(), Color::Gray); } #[test] fn parse_color_gray_vs_lightgray() { assert_eq!(parse_color("gray").unwrap(), Color::Gray); assert_eq!(parse_color("grey").unwrap(), Color::Gray); assert_eq!(parse_color("darkgray").unwrap(), Color::DarkGray); } }