Skip to main content

zeph_tui/theme/
presets.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Named theme presets embedded at compile time and user-defined theme file loading.
5//!
6//! Resolution order for a configured theme name:
7//! 1. Exact preset name match (case-sensitive).
8//! 2. User theme file `~/.config/zeph/themes/<name>.toml`.
9//! 3. Error with a clear message; callers should fall back to [`Preset::Zephyr`].
10//!
11//! Preset names take precedence over user files — a user cannot shadow a built-in
12//! preset by placing a file with the same name (security: prevents built-in override).
13
14use std::path::PathBuf;
15
16use super::palette::SemanticPalette;
17
18/// Error type for theme loading failures.
19#[derive(Debug, thiserror::Error)]
20#[non_exhaustive]
21pub enum ThemeLoadError {
22    /// The theme name contains a path traversal or separator.
23    #[error("unsafe theme name '{0}': must not contain path separators, '..', or be absolute")]
24    UnsafeName(String),
25    /// File size exceeds the 64 KiB safety cap.
26    #[error("theme file '{path}' exceeds 64 KiB limit ({size} bytes)")]
27    FileTooLarge { path: PathBuf, size: u64 },
28    /// The theme file could not be read.
29    #[error("failed to read theme file '{path}': {source}")]
30    Io {
31        path: PathBuf,
32        source: std::io::Error,
33    },
34    /// The TOML content could not be parsed as a [`SemanticPalette`].
35    #[error("failed to parse theme '{name}': {source}")]
36    Parse {
37        name: String,
38        #[source]
39        source: toml::de::Error,
40    },
41    /// No preset or user file found for the given name.
42    #[error("unknown theme '{name}': no built-in preset and no user file at '{path}'")]
43    NotFound { name: String, path: PathBuf },
44}
45
46/// Built-in named theme presets embedded at compile time.
47///
48/// # Examples
49///
50/// ```rust
51/// use zeph_tui::theme::presets::Preset;
52///
53/// let p = Preset::Zephyr.palette();
54/// assert_eq!(p.accent, zeph_tui::theme::palette::Rgb(0x1F, 0xB9, 0xA8));
55/// ```
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Preset {
58    /// Default dark aqua palette.
59    Zephyr,
60    /// Light variant of the Zephyr palette.
61    ZephyrLight,
62    /// Maximum contrast for accessibility.
63    HighContrast,
64    /// Maps legacy `Theme::default()` hardcoded colours to palette roles.
65    Classic,
66    /// Catppuccin Mocha dark palette.
67    CatppuccinMocha,
68    /// Gruvbox dark retro palette.
69    GruvboxDark,
70    /// Solarized dark palette.
71    SolarizedDark,
72}
73
74/// All preset variants — used in tests to ensure every variant parses successfully.
75pub 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    /// Parse and return the embedded [`SemanticPalette`] for this preset.
87    ///
88    /// # Panics
89    ///
90    /// Panics if the embedded TOML is invalid — guaranteed not to happen at runtime
91    /// because `#[test] all_presets_parse` covers every variant.
92    #[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    /// Return the raw embedded TOML source for this preset.
98    #[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    /// Resolve a theme name to a [`Preset`], or return `None` if unrecognised.
112    #[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
127/// Resolve a palette by name: preset match → user file → error.
128///
129/// # Security (M3)
130///
131/// - Rejects names containing path separators (`/`, `\`), `..`, or absolute paths.
132/// - Preset names take precedence — user files cannot shadow built-ins.
133/// - User files are capped at 64 KiB before parsing.
134///
135/// # Errors
136///
137/// Returns [`ThemeLoadError`] when the name is unsafe, the file cannot be read,
138/// is too large, cannot be parsed, or no match is found.
139pub fn resolve_palette(name: &str) -> Result<SemanticPalette, ThemeLoadError> {
140    // M3: validate name before joining into a path.
141    validate_theme_name(name)?;
142
143    // Preset names take precedence (prevents user shadowing built-ins).
144    if let Some(preset) = Preset::from_name(name) {
145        return Ok(preset.palette());
146    }
147
148    // Fall back to user theme file.
149    load_user_theme(name)
150}
151
152/// Validate a theme name for path safety (M3).
153///
154/// Exposed as `pub(crate)` so callers that split the preset/user-file fast path can
155/// validate the name before dispatching without going through `resolve_palette`.
156pub(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(()); // empty → zephyr default
163    }
164    // Reject absolute paths.
165    if name.starts_with('/') || name.starts_with('\\') {
166        return Err(ThemeLoadError::UnsafeName(name.to_owned()));
167    }
168    // Reject path separators and traversal.
169    if name.contains('/') || name.contains('\\') || name.contains("..") {
170        return Err(ThemeLoadError::UnsafeName(name.to_owned()));
171    }
172    // Reject Windows absolute paths like `C:\`.
173    if name.len() >= 2 && name.as_bytes()[1] == b':' {
174        return Err(ThemeLoadError::UnsafeName(name.to_owned()));
175    }
176    Ok(())
177}
178
179/// Load a user-defined theme from `~/.config/zeph/themes/<name>.toml`.
180///
181/// This is a synchronous, blocking function — call it only from a
182/// `tokio::task::spawn_blocking` context, never directly on an async executor thread.
183///
184/// # Errors
185///
186/// Returns [`ThemeLoadError`] if the name is not found, the file is too large, or
187/// the TOML cannot be parsed.
188pub(crate) fn load_user_theme(name: &str) -> Result<SemanticPalette, ThemeLoadError> {
189    use std::io::Read;
190
191    const MAX_SIZE: u64 = 64 * 1024; // 64 KiB
192
193    let themes_dir = user_themes_dir();
194    let path = themes_dir.join(format!("{name}.toml"));
195
196    // Reject symlinks before opening to prevent following links out of the themes dir.
197    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    // Read with an explicit byte cap — authoritative regardless of metadata/TOCTOU race.
209    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    /// S5: every Preset variant must parse without panicking.
245    #[test]
246    fn all_presets_parse() {
247        for &preset in ALL_PRESETS {
248            let _ = preset.palette(); // panics if TOML is invalid
249        }
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}