496 lines
19 KiB
Rust
Executable File
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);
|
|
}
|
|
} |