nirc-rs/src/config/mod.rs

496 lines
19 KiB
Rust
Executable File

//! 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<ServerEntry>,
/// 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<String, String>,
}
#[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<String>,
}
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<String>,
/// Password.
pub password: Option<String>,
/// Auto-join channels/listen address.
#[serde(default)]
pub auto_join: Vec<String>,
/// 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<String, String>,
}
#[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<String, String>,
/// 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<String>,
}
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<String>,
}
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::time::SystemTime> {
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<String, String>) -> 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<Color> {
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::<u8>()?;
let g = parts[1].trim().parse::<u8>()?;
let b = parts[2].trim().parse::<u8>()?;
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::core::message::ChatMessage>,
) -> 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);
}
}