zeph_tui/theme/
presets.rs1use std::path::PathBuf;
15
16use super::palette::SemanticPalette;
17
18#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum ThemeLoadError {
22 #[error("unsafe theme name '{0}': must not contain path separators, '..', or be absolute")]
24 UnsafeName(String),
25 #[error("theme file '{path}' exceeds 64 KiB limit ({size} bytes)")]
27 FileTooLarge { path: PathBuf, size: u64 },
28 #[error("failed to read theme file '{path}': {source}")]
30 Io {
31 path: PathBuf,
32 source: std::io::Error,
33 },
34 #[error("failed to parse theme '{name}': {source}")]
36 Parse {
37 name: String,
38 #[source]
39 source: toml::de::Error,
40 },
41 #[error("unknown theme '{name}': no built-in preset and no user file at '{path}'")]
43 NotFound { name: String, path: PathBuf },
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Preset {
58 Zephyr,
60 ZephyrLight,
62 HighContrast,
64 Classic,
66 CatppuccinMocha,
68 GruvboxDark,
70 SolarizedDark,
72}
73
74pub const ALL_PRESETS: &[Preset] = &[
76 Preset::Zephyr,
77 Preset::ZephyrLight,
78 Preset::HighContrast,
79 Preset::Classic,
80 Preset::CatppuccinMocha,
81 Preset::GruvboxDark,
82 Preset::SolarizedDark,
83];
84
85impl Preset {
86 #[must_use]
93 pub fn palette(self) -> SemanticPalette {
94 toml::from_str(self.toml_src()).expect("embedded preset TOML is always valid")
95 }
96
97 #[must_use]
99 pub fn toml_src(self) -> &'static str {
100 match self {
101 Self::Zephyr => include_str!("presets/zephyr.toml"),
102 Self::ZephyrLight => include_str!("presets/zephyr-light.toml"),
103 Self::HighContrast => include_str!("presets/high-contrast.toml"),
104 Self::Classic => include_str!("presets/classic.toml"),
105 Self::CatppuccinMocha => include_str!("presets/catppuccin-mocha.toml"),
106 Self::GruvboxDark => include_str!("presets/gruvbox-dark.toml"),
107 Self::SolarizedDark => include_str!("presets/solarized-dark.toml"),
108 }
109 }
110
111 #[must_use]
113 pub fn from_name(name: &str) -> Option<Self> {
114 match name {
115 "zephyr" | "" => Some(Self::Zephyr),
116 "zephyr-light" => Some(Self::ZephyrLight),
117 "high-contrast" => Some(Self::HighContrast),
118 "classic" => Some(Self::Classic),
119 "catppuccin-mocha" => Some(Self::CatppuccinMocha),
120 "gruvbox-dark" => Some(Self::GruvboxDark),
121 "solarized-dark" => Some(Self::SolarizedDark),
122 _ => None,
123 }
124 }
125}
126
127pub fn resolve_palette(name: &str) -> Result<SemanticPalette, ThemeLoadError> {
140 validate_theme_name(name)?;
142
143 if let Some(preset) = Preset::from_name(name) {
145 return Ok(preset.palette());
146 }
147
148 load_user_theme(name)
150}
151
152pub(crate) fn validate_theme_name_pub(name: &str) -> Result<(), ThemeLoadError> {
157 validate_theme_name(name)
158}
159
160fn validate_theme_name(name: &str) -> Result<(), ThemeLoadError> {
161 if name.is_empty() {
162 return Ok(()); }
164 if name.starts_with('/') || name.starts_with('\\') {
166 return Err(ThemeLoadError::UnsafeName(name.to_owned()));
167 }
168 if name.contains('/') || name.contains('\\') || name.contains("..") {
170 return Err(ThemeLoadError::UnsafeName(name.to_owned()));
171 }
172 if name.len() >= 2 && name.as_bytes()[1] == b':' {
174 return Err(ThemeLoadError::UnsafeName(name.to_owned()));
175 }
176 Ok(())
177}
178
179pub(crate) fn load_user_theme(name: &str) -> Result<SemanticPalette, ThemeLoadError> {
189 use std::io::Read;
190
191 const MAX_SIZE: u64 = 64 * 1024; let themes_dir = user_themes_dir();
194 let path = themes_dir.join(format!("{name}.toml"));
195
196 let meta = std::fs::symlink_metadata(&path).map_err(|_| ThemeLoadError::NotFound {
198 name: name.to_owned(),
199 path: path.clone(),
200 })?;
201 if meta.file_type().is_symlink() {
202 return Err(ThemeLoadError::NotFound {
203 name: name.to_owned(),
204 path,
205 });
206 }
207
208 let f = std::fs::File::open(&path).map_err(|e| ThemeLoadError::Io {
210 path: path.clone(),
211 source: e,
212 })?;
213 let mut buf = String::new();
214 f.take(MAX_SIZE + 1)
215 .read_to_string(&mut buf)
216 .map_err(|e| ThemeLoadError::Io {
217 path: path.clone(),
218 source: e,
219 })?;
220 if buf.len() as u64 > MAX_SIZE {
221 return Err(ThemeLoadError::FileTooLarge {
222 path,
223 size: buf.len() as u64,
224 });
225 }
226
227 toml::from_str(&buf).map_err(|e| ThemeLoadError::Parse {
228 name: name.to_owned(),
229 source: e,
230 })
231}
232
233fn user_themes_dir() -> PathBuf {
234 dirs::config_dir()
235 .unwrap_or_else(|| PathBuf::from("~/.config"))
236 .join("zeph")
237 .join("themes")
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
246 fn all_presets_parse() {
247 for &preset in ALL_PRESETS {
248 let _ = preset.palette(); }
250 }
251
252 #[test]
253 fn preset_from_name_roundtrip() {
254 assert_eq!(Preset::from_name("zephyr"), Some(Preset::Zephyr));
255 assert_eq!(Preset::from_name(""), Some(Preset::Zephyr));
256 assert_eq!(Preset::from_name("gruvbox-dark"), Some(Preset::GruvboxDark));
257 assert_eq!(Preset::from_name("unknown"), None);
258 }
259
260 #[test]
261 fn validate_name_rejects_traversal() {
262 assert!(validate_theme_name("../etc/passwd").is_err());
263 assert!(validate_theme_name("/absolute").is_err());
264 assert!(validate_theme_name("path/sep").is_err());
265 assert!(validate_theme_name("path\\sep").is_err());
266 }
267
268 #[test]
269 fn validate_name_accepts_valid() {
270 assert!(validate_theme_name("").is_ok());
271 assert!(validate_theme_name("zephyr").is_ok());
272 assert!(validate_theme_name("my-custom-theme").is_ok());
273 assert!(validate_theme_name("theme123").is_ok());
274 }
275
276 #[test]
277 fn resolve_palette_zephyr_default() {
278 let p = resolve_palette("").unwrap();
279 assert_eq!(p.accent, crate::theme::palette::Rgb(0x1F, 0xB9, 0xA8));
280 }
281
282 #[test]
283 fn resolve_palette_unsafe_name_error() {
284 assert!(resolve_palette("../evil").is_err());
285 assert!(resolve_palette("/etc/passwd").is_err());
286 }
287}