use std::path::PathBuf;
use super::palette::SemanticPalette;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ThemeLoadError {
#[error("unsafe theme name '{0}': must not contain path separators, '..', or be absolute")]
UnsafeName(String),
#[error("theme file '{path}' exceeds 64 KiB limit ({size} bytes)")]
FileTooLarge { path: PathBuf, size: u64 },
#[error("failed to read theme file '{path}': {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse theme '{name}': {source}")]
Parse {
name: String,
#[source]
source: toml::de::Error,
},
#[error("unknown theme '{name}': no built-in preset and no user file at '{path}'")]
NotFound { name: String, path: PathBuf },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preset {
Zephyr,
ZephyrLight,
HighContrast,
Classic,
CatppuccinMocha,
GruvboxDark,
SolarizedDark,
}
pub const ALL_PRESETS: &[Preset] = &[
Preset::Zephyr,
Preset::ZephyrLight,
Preset::HighContrast,
Preset::Classic,
Preset::CatppuccinMocha,
Preset::GruvboxDark,
Preset::SolarizedDark,
];
impl Preset {
#[must_use]
pub fn palette(self) -> SemanticPalette {
toml::from_str(self.toml_src()).expect("embedded preset TOML is always valid")
}
#[must_use]
pub fn toml_src(self) -> &'static str {
match self {
Self::Zephyr => include_str!("presets/zephyr.toml"),
Self::ZephyrLight => include_str!("presets/zephyr-light.toml"),
Self::HighContrast => include_str!("presets/high-contrast.toml"),
Self::Classic => include_str!("presets/classic.toml"),
Self::CatppuccinMocha => include_str!("presets/catppuccin-mocha.toml"),
Self::GruvboxDark => include_str!("presets/gruvbox-dark.toml"),
Self::SolarizedDark => include_str!("presets/solarized-dark.toml"),
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
match name {
"zephyr" | "" => Some(Self::Zephyr),
"zephyr-light" => Some(Self::ZephyrLight),
"high-contrast" => Some(Self::HighContrast),
"classic" => Some(Self::Classic),
"catppuccin-mocha" => Some(Self::CatppuccinMocha),
"gruvbox-dark" => Some(Self::GruvboxDark),
"solarized-dark" => Some(Self::SolarizedDark),
_ => None,
}
}
}
pub fn resolve_palette(name: &str) -> Result<SemanticPalette, ThemeLoadError> {
validate_theme_name(name)?;
if let Some(preset) = Preset::from_name(name) {
return Ok(preset.palette());
}
load_user_theme(name)
}
pub(crate) fn validate_theme_name_pub(name: &str) -> Result<(), ThemeLoadError> {
validate_theme_name(name)
}
fn validate_theme_name(name: &str) -> Result<(), ThemeLoadError> {
if name.is_empty() {
return Ok(()); }
if name.starts_with('/') || name.starts_with('\\') {
return Err(ThemeLoadError::UnsafeName(name.to_owned()));
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(ThemeLoadError::UnsafeName(name.to_owned()));
}
if name.len() >= 2 && name.as_bytes()[1] == b':' {
return Err(ThemeLoadError::UnsafeName(name.to_owned()));
}
Ok(())
}
pub(crate) fn load_user_theme(name: &str) -> Result<SemanticPalette, ThemeLoadError> {
use std::io::Read;
const MAX_SIZE: u64 = 64 * 1024;
let themes_dir = user_themes_dir();
let path = themes_dir.join(format!("{name}.toml"));
let meta = std::fs::symlink_metadata(&path).map_err(|_| ThemeLoadError::NotFound {
name: name.to_owned(),
path: path.clone(),
})?;
if meta.file_type().is_symlink() {
return Err(ThemeLoadError::NotFound {
name: name.to_owned(),
path,
});
}
let f = std::fs::File::open(&path).map_err(|e| ThemeLoadError::Io {
path: path.clone(),
source: e,
})?;
let mut buf = String::new();
f.take(MAX_SIZE + 1)
.read_to_string(&mut buf)
.map_err(|e| ThemeLoadError::Io {
path: path.clone(),
source: e,
})?;
if buf.len() as u64 > MAX_SIZE {
return Err(ThemeLoadError::FileTooLarge {
path,
size: buf.len() as u64,
});
}
toml::from_str(&buf).map_err(|e| ThemeLoadError::Parse {
name: name.to_owned(),
source: e,
})
}
fn user_themes_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("zeph")
.join("themes")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_presets_parse() {
for &preset in ALL_PRESETS {
let _ = preset.palette(); }
}
#[test]
fn preset_from_name_roundtrip() {
assert_eq!(Preset::from_name("zephyr"), Some(Preset::Zephyr));
assert_eq!(Preset::from_name(""), Some(Preset::Zephyr));
assert_eq!(Preset::from_name("gruvbox-dark"), Some(Preset::GruvboxDark));
assert_eq!(Preset::from_name("unknown"), None);
}
#[test]
fn validate_name_rejects_traversal() {
assert!(validate_theme_name("../etc/passwd").is_err());
assert!(validate_theme_name("/absolute").is_err());
assert!(validate_theme_name("path/sep").is_err());
assert!(validate_theme_name("path\\sep").is_err());
}
#[test]
fn validate_name_accepts_valid() {
assert!(validate_theme_name("").is_ok());
assert!(validate_theme_name("zephyr").is_ok());
assert!(validate_theme_name("my-custom-theme").is_ok());
assert!(validate_theme_name("theme123").is_ok());
}
#[test]
fn resolve_palette_zephyr_default() {
let p = resolve_palette("").unwrap();
assert_eq!(p.accent, crate::theme::palette::Rgb(0x1F, 0xB9, 0xA8));
}
#[test]
fn resolve_palette_unsafe_name_error() {
assert!(resolve_palette("../evil").is_err());
assert!(resolve_palette("/etc/passwd").is_err());
}
}