use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, RwLock};
use ratatui::style::{Color, Style};
use serde::{Deserialize, Serialize};
use crate::error::{SnipError, SnipResult};
use crate::utils::config::get_config_dir;
#[derive(Clone, Copy, Debug)]
pub struct Theme {
pub primary: Color,
pub secondary: Color,
pub accent: Color,
pub background: Color,
pub text: Color,
pub border: Color,
pub selected_bg: Color,
pub muted: Color,
pub string_color: Color,
pub escape_color: Color,
}
const DARK_THEME: Theme = Theme {
primary: Color::Blue,
secondary: Color::Cyan,
accent: Color::Yellow,
background: Color::Black,
text: Color::White,
border: Color::Cyan,
selected_bg: Color::Blue,
muted: Color::Gray,
string_color: Color::Green,
escape_color: Color::Magenta,
};
const BRIGHT_THEME: Theme = Theme {
primary: Color::Blue,
secondary: Color::Blue,
accent: Color::Magenta,
background: Color::White,
text: Color::Black,
border: Color::Blue,
selected_bg: Color::LightBlue,
muted: Color::Gray,
string_color: Color::DarkGray,
escape_color: Color::DarkGray,
};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyTheme {
general: HalloyGeneral,
text: HalloyText,
buffer: HalloyBuffer,
buttons: HalloyButtons,
formatting: HalloyFormatting,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyGeneral {
background: String,
border: String,
horizontal_rule: String,
horizontal_rule_text: Option<String>,
scrollbar: Option<String>,
unread_indicator: String,
highlight_indicator: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyText {
primary: HalloyTextStyle,
secondary: HalloyTextStyle,
tertiary: HalloyTextStyle,
success: HalloyTextStyle,
error: HalloyTextStyle,
warning: HalloyOptionalTextStyle,
info: HalloyOptionalTextStyle,
debug: HalloyOptionalTextStyle,
trace: HalloyOptionalTextStyle,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyBuffer {
action: HalloyTextStyle,
background: String,
background_text_input: String,
background_title_bar: String,
border: String,
border_selected: String,
code: HalloyTextStyle,
highlight: String,
nickname: HalloyTextStyle,
selection: String,
server_messages: HalloyServerMessages,
timestamp: HalloyTextStyle,
topic: HalloyTextStyle,
url: HalloyTextStyle,
nickname_offline: HalloyOptionalTextStyle,
backlog_rule: Option<String>,
backlog_rule_text: Option<String>,
date_rule: Option<String>,
date_rule_text: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyServerMessages {
default: HalloyTextStyle,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyButtons {
primary: HalloyButton,
secondary: HalloyButton,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyButton {
background: String,
background_hover: String,
background_selected: String,
background_selected_hover: String,
border_active: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct HalloyFormatting {
white: Option<String>,
black: Option<String>,
blue: Option<String>,
green: Option<String>,
red: Option<String>,
brown: Option<String>,
magenta: Option<String>,
orange: Option<String>,
yellow: Option<String>,
lightgreen: Option<String>,
cyan: Option<String>,
lightcyan: Option<String>,
lightblue: Option<String>,
pink: Option<String>,
grey: Option<String>,
lightgrey: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct HalloyTextStyle {
color: String,
#[allow(dead_code)]
font_style: Option<FontStyle>,
}
impl<'de> Deserialize<'de> for HalloyTextStyle {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Basic(String),
Extended {
color: String,
font_style: Option<FontStyle>,
},
}
let repr = Repr::deserialize(deserializer)?;
let (color, font_style) = match repr {
Repr::Basic(c) => (c, None),
Repr::Extended { color, font_style } => (color, font_style),
};
Ok(HalloyTextStyle { color, font_style })
}
}
impl Serialize for HalloyTextStyle {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if self.font_style.is_some() {
use serde::ser::SerializeStruct;
let mut s = serializer.serialize_struct("TextStyle", 2)?;
s.serialize_field("color", &self.color)?;
s.serialize_field("font_style", &self.font_style)?;
s.end()
} else {
self.color.serialize(serializer)
}
}
}
#[derive(Debug, Clone, Default)]
struct HalloyOptionalTextStyle {
color: Option<String>,
#[allow(dead_code)]
font_style: Option<FontStyle>,
}
impl<'de> Deserialize<'de> for HalloyOptionalTextStyle {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Basic(Option<String>),
Extended {
color: Option<String>,
font_style: Option<FontStyle>,
},
}
let repr = Repr::deserialize(deserializer)?;
let (color, font_style) = match repr {
Repr::Basic(c) => (c, None),
Repr::Extended { color, font_style } => (color, font_style),
};
Ok(HalloyOptionalTextStyle { color, font_style })
}
}
impl Serialize for HalloyOptionalTextStyle {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if self.font_style.is_some() {
use serde::ser::SerializeStruct;
let mut s = serializer.serialize_struct("OptionalTextStyle", 2)?;
s.serialize_field("color", &self.color)?;
s.serialize_field("font_style", &self.font_style)?;
s.end()
} else {
self.color.serialize(serializer)
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[allow(dead_code)]
enum FontStyle {
#[default]
Normal,
Bold,
Italic,
#[serde(alias = "bold-italic")]
ItalicBold,
}
fn hex_to_color(s: &str) -> Option<Color> {
let hex = s.strip_prefix('#')?;
let bytes = hex.as_bytes();
let (r, g, b) = match bytes.len() {
6 => (
u8::from_str_radix(std::str::from_utf8(&bytes[0..2]).ok()?, 16).ok()?,
u8::from_str_radix(std::str::from_utf8(&bytes[2..4]).ok()?, 16).ok()?,
u8::from_str_radix(std::str::from_utf8(&bytes[4..6]).ok()?, 16).ok()?,
),
8 => (
u8::from_str_radix(std::str::from_utf8(&bytes[0..2]).ok()?, 16).ok()?,
u8::from_str_radix(std::str::from_utf8(&bytes[2..4]).ok()?, 16).ok()?,
u8::from_str_radix(std::str::from_utf8(&bytes[4..6]).ok()?, 16).ok()?,
),
_ => return None,
};
Some(Color::Rgb(r, g, b))
}
fn opt_str_to_color(s: &Option<String>) -> Color {
s.as_deref().and_then(hex_to_color).unwrap_or(Color::Reset)
}
impl HalloyTheme {
fn into_snp_theme(self) -> Theme {
let text_primary = hex_to_color(&self.text.primary.color).unwrap_or(Color::Reset);
let text_secondary = hex_to_color(&self.text.secondary.color).unwrap_or(Color::Reset);
let text_tertiary = hex_to_color(&self.text.tertiary.color).unwrap_or(Color::Reset);
let text_success = hex_to_color(&self.text.success.color).unwrap_or(Color::Reset);
let text_error = hex_to_color(&self.text.error.color).unwrap_or(Color::Reset);
let general_bg = hex_to_color(&self.general.background).unwrap_or(Color::Reset);
let general_border = hex_to_color(&self.general.border).unwrap_or(Color::Reset);
let buffer_bg = hex_to_color(&self.buffer.background).unwrap_or(Color::Reset);
let buffer_highlight = hex_to_color(&self.buffer.highlight).unwrap_or(Color::Reset);
let buffer_selection = hex_to_color(&self.buffer.selection).unwrap_or(Color::Reset);
let buffer_timestamp = hex_to_color(&self.buffer.timestamp.color).unwrap_or(Color::Reset);
let formatting_green = opt_str_to_color(&self.formatting.green);
let formatting_magenta = opt_str_to_color(&self.formatting.magenta);
Theme {
primary: first_some_color(&[text_primary, text_tertiary]),
secondary: first_some_color(&[text_tertiary, text_secondary]),
accent: first_some_color(&[text_tertiary, text_success]),
background: first_some_color(&[general_bg, buffer_bg]),
text: first_some_color(&[text_primary]),
border: first_some_color(&[general_border, text_tertiary]),
selected_bg: first_some_color(&[buffer_highlight, buffer_selection]),
muted: first_some_color(&[buffer_timestamp, text_secondary]),
string_color: first_some_color(&[formatting_green, text_success]),
escape_color: first_some_color(&[formatting_magenta, text_error]),
}
}
}
fn first_some_color(candidates: &[Color]) -> Color {
for &c in candidates {
if c != Color::Reset {
return c;
}
}
Color::White
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ThemesConfig {
#[serde(default)]
pub active: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct ThemeInfo {
pub name: String,
pub path: PathBuf,
pub is_bundled: bool,
}
#[allow(dead_code)]
pub struct ThemeManager {
#[allow(dead_code)]
config_dir: PathBuf,
#[allow(dead_code)]
themes_dir: PathBuf,
#[allow(dead_code)]
config_path: PathBuf,
#[allow(dead_code)]
config: ThemesConfig,
}
#[allow(dead_code)]
impl ThemeManager {
pub fn new() -> SnipResult<Self> {
let config_dir = get_config_dir();
let themes_dir = config_dir.join("themes");
let config_path = config_dir.join("themes.toml");
let config = if config_path.exists() {
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<ThemesConfig>(&content) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
config = %config_path.display(),
error = %e,
"Failed to parse themes.toml; using defaults"
);
ThemesConfig::default()
}
},
Err(e) => {
tracing::warn!(
config = %config_path.display(),
error = %e,
"Failed to read themes.toml; using defaults"
);
ThemesConfig::default()
}
}
} else {
ThemesConfig::default()
};
Ok(Self {
config_dir,
themes_dir,
config_path,
config,
})
}
pub fn init_themes_dir(&mut self) -> SnipResult<()> {
fs::create_dir_all(&self.themes_dir).map_err(|e| {
SnipError::io_error("create themes directory", self.themes_dir.clone(), e)
})?;
write_theme_if_missing(
&self.themes_dir,
bundled_default_name(),
super::_generated_bundled_themes::DEFAULT_BUNDLED,
)?;
for (name, toml_text) in super::_generated_bundled_themes::bundled_themes_decoded()? {
write_theme_if_missing(&self.themes_dir, &name, &toml_text)?;
}
Ok(())
}
pub fn list_themes(&self) -> SnipResult<Vec<ThemeInfo>> {
if !self.themes_dir.exists() {
return Ok(Vec::new());
}
let mut themes = Vec::new();
for entry in fs::read_dir(&self.themes_dir)
.map_err(|e| SnipError::io_error("read themes directory", self.themes_dir.clone(), e))?
{
let entry = entry
.map_err(|e| SnipError::io_error("read theme entry", self.themes_dir.clone(), e))?;
let path = entry.path();
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let name = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let is_bundled = is_bundled_theme_name(&name);
themes.push(ThemeInfo {
name,
path,
is_bundled,
});
}
themes.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(themes)
}
pub fn load_theme(&self, name: &str) -> SnipResult<Theme> {
validate_theme_name(name)
.map_err(|(msg, detail)| SnipError::runtime_error(msg, Some(detail)))?;
let path = self.theme_path(name);
let content = fs::read_to_string(&path)
.map_err(|e| SnipError::io_error("read theme file", path.clone(), e))?;
let halloy: HalloyTheme =
toml::from_str(&content).map_err(|e| SnipError::toml_error("parse theme", e))?;
Ok(halloy.into_snp_theme())
}
pub fn get_active_theme_name(&self) -> Option<String> {
self.config.active.clone()
}
pub fn set_active_theme(&mut self, name: &str) -> SnipResult<()> {
validate_theme_name(name)
.map_err(|(msg, detail)| SnipError::runtime_error(msg, Some(detail)))?;
self.config.active = Some(name.to_string());
self.save_config()
}
pub fn themes_dir(&self) -> &Path {
&self.themes_dir
}
fn theme_path(&self, name: &str) -> PathBuf {
self.themes_dir.join(format!("{name}.toml"))
}
fn save_config(&self) -> SnipResult<()> {
if let Some(parent) = self.config_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| SnipError::io_error("create config directory", parent, e))?;
}
let toml_str = toml::to_string_pretty(&self.config)
.map_err(|e| SnipError::toml_error("serialize themes config", e))?;
let tmp_path = self.config_path.with_file_name(format!(
"{}.{}.tmp",
self.config_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("themes"),
uuid::Uuid::new_v4()
));
let guard = crate::utils::tempfile_guard::TempFileGuard::new(tmp_path.clone());
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true).mode(0o600);
let mut file = opts.open(&tmp_path).map_err(|e| {
SnipError::io_error("create themes config temp", tmp_path.clone(), e)
})?;
use std::io::Write;
file.write_all(toml_str.as_bytes())
.map_err(|e| SnipError::io_error("write themes config", tmp_path.clone(), e))?;
}
#[cfg(not(unix))]
{
fs::write(&tmp_path, toml_str)
.map_err(|e| SnipError::io_error("write themes config", tmp_path.clone(), e))?;
}
fs::rename(&tmp_path, &self.config_path).map_err(|e| {
SnipError::io_error("rename themes config", self.config_path.clone(), e)
})?;
guard.persist();
Ok(())
}
}
#[allow(dead_code)]
fn is_bundled_theme_name(name: &str) -> bool {
if name == bundled_default_name() {
return true;
}
super::_generated_bundled_themes::BUNDLED
.iter()
.any(|b| b.name == name)
}
#[allow(dead_code)]
fn bundled_default_name() -> &'static str {
"Cyber Red"
}
#[allow(dead_code)]
fn write_theme_if_missing(themes_dir: &Path, name: &str, content: &str) -> SnipResult<()> {
let path = themes_dir.join(format!("{name}.toml"));
if path.exists() {
return Ok(());
}
fs::write(&path, content)
.map_err(|e| SnipError::io_error("write bundled theme", path.clone(), e))
}
#[allow(dead_code)]
fn validate_theme_name(name: &str) -> Result<(), (&'static str, &'static str)> {
if name.is_empty() {
return Err(("Invalid theme name", "Theme name cannot be empty"));
}
if name.len() > 100 {
return Err((
"Invalid theme name",
"Theme name cannot exceed 100 characters",
));
}
if name.contains('/') || name.contains('\\') || name.contains('\0') {
return Err((
"Invalid theme name",
"Theme name cannot contain path separators or NUL bytes",
));
}
if name.contains("..") {
return Err(("Invalid theme name", "Theme name cannot contain '..'"));
}
Ok(())
}
static ACTIVE_THEME: LazyLock<RwLock<Theme>> = LazyLock::new(|| RwLock::new(load_initial_theme()));
pub fn get_theme() -> Theme {
*ACTIVE_THEME.read().unwrap_or_else(|e| e.into_inner())
}
#[allow(dead_code)]
pub fn set_active_theme(theme: Theme) {
*ACTIVE_THEME.write().unwrap_or_else(|e| e.into_inner()) = theme;
}
fn load_initial_theme() -> Theme {
if let Some(theme) = load_from_themes_toml() {
return theme;
}
if let Ok(value) = std::env::var("SNP_THEME")
&& let Some(theme) = resolve_legacy_or_filename(&value)
{
return theme;
}
if let Some(theme) = parse_halloy_string(super::_generated_bundled_themes::DEFAULT_BUNDLED) {
return theme;
}
DARK_THEME
}
fn load_from_themes_toml() -> Option<Theme> {
let config_dir = get_config_dir();
let config_path = config_dir.join("themes.toml");
if !config_path.exists() {
return None;
}
let content = fs::read_to_string(&config_path).ok()?;
let cfg: ThemesConfig = toml::from_str(&content).ok()?;
let name = cfg.active?;
let themes_dir = config_dir.join("themes");
let theme_path = themes_dir.join(format!("{name}.toml"));
let content = fs::read_to_string(&theme_path).ok()?;
parse_halloy_string(&content)
}
fn resolve_legacy_or_filename(value: &str) -> Option<Theme> {
match value {
"dark" => Some(DARK_THEME),
"bright" | "light" => Some(BRIGHT_THEME),
"auto" => {
let is_light = std::env::var("COLORFGBG")
.map(|v| v.starts_with("15;") || v.starts_with("7;"))
.unwrap_or(false);
Some(if is_light { BRIGHT_THEME } else { DARK_THEME })
}
other => {
if other.contains('/') || other.contains('\\') || other.contains("..") {
tracing::warn!(
"Invalid theme name '{}' contains path separators, ignoring",
other
);
return None;
}
let themes_dir = get_config_dir().join("themes");
let path = themes_dir.join(format!("{other}.toml"));
let content = fs::read_to_string(&path).ok()?;
parse_halloy_string(&content)
}
}
}
fn parse_halloy_string(s: &str) -> Option<Theme> {
let halloy: HalloyTheme = toml::from_str(s).ok()?;
Some(halloy.into_snp_theme())
}
pub(crate) fn style_fg(fg: Color) -> Style {
Style::default().fg(fg)
}
pub(crate) fn style_fg_bg(fg: Color, bg: Color) -> Style {
Style::default().fg(fg).bg(bg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_halloy_ferra_matches_expected_palette() {
let toml = include_str!("../../themes/ferra.toml");
let halloy: HalloyTheme = toml::from_str(toml).expect("parse ferra");
let theme = halloy.into_snp_theme();
assert!(matches!(theme.primary, Color::Rgb(_, _, _)));
assert!(matches!(theme.background, Color::Rgb(_, _, _)));
assert_ne!(theme.background, Color::White);
}
#[test]
fn parse_halloy_partial_only_general() {
let toml = r##"
[general]
background = "#112233"
border = "#445566"
"##;
let halloy: HalloyTheme = toml::from_str(toml).expect("parse partial");
let theme = halloy.into_snp_theme();
assert_eq!(theme.background, Color::Rgb(0x11, 0x22, 0x33));
assert_eq!(theme.border, Color::Rgb(0x44, 0x55, 0x66));
assert_eq!(theme.text, Color::White);
}
#[test]
fn parse_halloy_with_alpha_hex() {
assert_eq!(hex_to_color("#FF0000"), Some(Color::Rgb(255, 0, 0)));
assert_eq!(hex_to_color("#FF0000AA"), Some(Color::Rgb(255, 0, 0)));
assert_eq!(hex_to_color("#73000054"), Some(Color::Rgb(0x73, 0, 0)));
assert_eq!(hex_to_color("#FFF"), None);
assert_eq!(hex_to_color(""), None);
assert_eq!(hex_to_color("not a color"), None);
}
#[test]
fn parse_halloy_unknown_section_ignored() {
let toml = r##"
[general]
background = "#000000"
unknown_field = "ignored"
[some_unknown_section]
foo = "bar"
"##;
let halloy: HalloyTheme = toml::from_str(toml).expect("parse with unknown");
assert_eq!(halloy.general.background, "#000000");
assert_eq!(
hex_to_color(&halloy.general.background),
Some(Color::Rgb(0, 0, 0))
);
}
#[test]
fn parse_halloy_extended_text_style() {
let toml = r##"
[text]
primary = { color = "#FF0000", font_style = "bold" }
"##;
let halloy: HalloyTheme = toml::from_str(toml).expect("parse extended");
assert_eq!(halloy.text.primary.color, "#FF0000");
assert_eq!(halloy.text.primary.font_style, Some(FontStyle::Bold));
assert_eq!(
hex_to_color(&halloy.text.primary.color),
Some(Color::Rgb(255, 0, 0))
);
}
#[test]
fn parse_halloy_kebab_font_style() {
let toml = r##"
[text]
primary = { color = "#FF0000", font_style = "bold-italic" }
"##;
let halloy: HalloyTheme = toml::from_str(toml).expect("parse kebab font_style");
assert_eq!(halloy.text.primary.font_style, Some(FontStyle::ItalicBold));
}
#[test]
fn mapping_chains_fallbacks() {
let halloy = HalloyTheme {
text: HalloyText {
success: HalloyTextStyle {
color: "#010203".into(),
font_style: None,
},
..Default::default()
},
..Default::default()
};
let theme = halloy.into_snp_theme();
assert_eq!(theme.primary, Color::White);
assert_eq!(theme.accent, Color::Rgb(1, 2, 3));
}
#[test]
fn mapping_handles_transparent() {
let halloy = HalloyTheme::default();
let theme = halloy.into_snp_theme();
assert_eq!(theme.primary, Color::White);
assert_eq!(theme.text, Color::White);
}
#[test]
fn theme_manager_init_seeds_bundled() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = ThemeManager {
config_dir: tmp.path().to_path_buf(),
themes_dir: tmp.path().join("themes"),
config_path: tmp.path().join("themes.toml"),
config: ThemesConfig::default(),
};
mgr.init_themes_dir().expect("init");
let listed = mgr.list_themes().expect("list");
assert!(
listed.len() >= 40,
"expected ~50 themes, got {}",
listed.len()
);
let cyber = listed
.iter()
.find(|t| t.name == "Cyber Red")
.expect("cyber-red");
assert!(cyber.is_bundled);
let ferra = listed.iter().find(|t| t.name == "ferra").expect("ferra");
assert!(ferra.is_bundled);
}
#[test]
fn theme_manager_init_preserves_user_files() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = ThemeManager {
config_dir: tmp.path().to_path_buf(),
themes_dir: tmp.path().join("themes"),
config_path: tmp.path().join("themes.toml"),
config: ThemesConfig::default(),
};
mgr.init_themes_dir().expect("first init");
let path = mgr.themes_dir().join("ferra.toml");
std::fs::write(&path, "# my custom ferra\n").unwrap();
mgr.init_themes_dir().expect("second init");
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("# my custom ferra"));
}
#[test]
fn theme_manager_init_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = ThemeManager {
config_dir: tmp.path().to_path_buf(),
themes_dir: tmp.path().join("themes"),
config_path: tmp.path().join("themes.toml"),
config: ThemesConfig::default(),
};
mgr.init_themes_dir().expect("1");
mgr.init_themes_dir().expect("2");
mgr.init_themes_dir().expect("3");
}
#[test]
fn theme_manager_load_active_persists() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = ThemeManager {
config_dir: tmp.path().to_path_buf(),
themes_dir: tmp.path().join("themes"),
config_path: tmp.path().join("themes.toml"),
config: ThemesConfig::default(),
};
mgr.init_themes_dir().unwrap();
mgr.set_active_theme("ferra").expect("set");
let raw = std::fs::read_to_string(&mgr.config_path).unwrap();
let cfg2: ThemesConfig = toml::from_str(&raw).expect("parse");
assert_eq!(cfg2.active.as_deref(), Some("ferra"));
}
#[test]
fn theme_manager_rejects_path_traversal() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = ThemeManager {
config_dir: tmp.path().to_path_buf(),
themes_dir: tmp.path().join("themes"),
config_path: tmp.path().join("themes.toml"),
config: ThemesConfig::default(),
};
mgr.init_themes_dir().unwrap();
assert!(mgr.set_active_theme("../etc/passwd").is_err());
assert!(mgr.set_active_theme("").is_err());
assert!(mgr.set_active_theme("foo/bar").is_err());
assert!(mgr.set_active_theme("a\\b").is_err());
assert!(mgr.set_active_theme("..").is_err());
assert!(mgr.load_theme("foo/../bar").is_err());
}
#[test]
fn theme_manager_load_parses_real_theme() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = ThemeManager {
config_dir: tmp.path().to_path_buf(),
themes_dir: tmp.path().join("themes"),
config_path: tmp.path().join("themes.toml"),
config: ThemesConfig::default(),
};
mgr.init_themes_dir().unwrap();
let ferra = mgr.load_theme("ferra").expect("load ferra");
assert_eq!(ferra.primary, Color::Rgb(0xFE, 0xCD, 0xB2));
}
#[test]
fn theme_manager_list_sorted() {
let tmp = tempfile::TempDir::new().unwrap();
let mut mgr = ThemeManager {
config_dir: tmp.path().to_path_buf(),
themes_dir: tmp.path().join("themes"),
config_path: tmp.path().join("themes.toml"),
config: ThemesConfig::default(),
};
mgr.init_themes_dir().unwrap();
let listed = mgr.list_themes().expect("list");
let names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect();
let mut sorted = names.clone();
sorted.sort_by_key(|s| s.to_lowercase());
assert_eq!(names, sorted, "themes should be sorted alphabetically");
}
#[test]
fn set_active_theme_visible_to_get_theme() {
let custom = Theme {
primary: Color::Rgb(1, 2, 3),
secondary: Color::Rgb(4, 5, 6),
accent: Color::Rgb(7, 8, 9),
background: Color::Rgb(10, 11, 12),
text: Color::Rgb(13, 14, 15),
border: Color::Rgb(16, 17, 18),
selected_bg: Color::Rgb(19, 20, 21),
muted: Color::Rgb(22, 23, 24),
string_color: Color::Rgb(25, 26, 27),
escape_color: Color::Rgb(28, 29, 30),
};
set_active_theme(custom);
let got = get_theme();
assert_eq!(got.primary, Color::Rgb(1, 2, 3));
set_active_theme(DARK_THEME);
assert_eq!(get_theme().primary, DARK_THEME.primary);
}
#[test]
fn default_bundled_is_valid() {
let s = super::super::_generated_bundled_themes::DEFAULT_BUNDLED;
let halloy: HalloyTheme = toml::from_str(s).expect("parse bundled default");
let _ = halloy.into_snp_theme();
}
#[test]
fn resolve_legacy_dark_and_bright() {
assert_eq!(
resolve_legacy_or_filename("dark").map(|t| t.background),
Some(DARK_THEME.background),
);
assert_eq!(
resolve_legacy_or_filename("bright").map(|t| t.background),
Some(BRIGHT_THEME.background),
);
assert_eq!(
resolve_legacy_or_filename("light").map(|t| t.background),
Some(BRIGHT_THEME.background),
);
assert!(resolve_legacy_or_filename("auto").is_some());
}
}