返回 CodeWhale
user_theme.rs
根目录 / crates / palette / src / user_theme.rs
1 //! User-authored theme overlays loaded from the Codewhale-owned themes directory.
2
3 use std::fs::{self, File, OpenOptions};
4 use std::io::Read;
5 use std::path::{Path, PathBuf};
6
7 use ratatui::style::Color;
8 use serde::Deserialize;
9
10 use super::{ThemeId, UiTheme, parse_hex_rgb_color};
11
12 pub const USER_THEME_PREFIX: &str = "custom:";
13 pub const USER_THEME_SCHEMA: &str = include_str!("../assets/user-theme.schema.json");
14 const MAX_USER_THEME_BYTES: u64 = 64 * 1024;
15
16 /// A validated user-authored overlay available to the theme picker.
17 #[derive(Debug, Clone)]
18 pub struct UserThemeOption {
19 pub selector: String,
20 pub base: ThemeId,
21 pub theme: UiTheme,
22 }
23
24 #[derive(Debug, Deserialize)]
25 #[serde(deny_unknown_fields)]
26 struct UserThemeFile {
27 schema_version: u8,
28 base: String,
29 colors: UserThemeColors,
30 }
31
32 #[derive(Debug, Default, Deserialize)]
33 #[serde(default, deny_unknown_fields)]
34 struct UserThemeColors {
35 surface_bg: Option<String>,
36 panel_bg: Option<String>,
37 elevated_bg: Option<String>,
38 composer_bg: Option<String>,
39 selection_bg: Option<String>,
40 header_bg: Option<String>,
41 footer_bg: Option<String>,
42 text_dim: Option<String>,
43 text_hint: Option<String>,
44 text_muted: Option<String>,
45 text_body: Option<String>,
46 text_soft: Option<String>,
47 border: Option<String>,
48 accent_primary: Option<String>,
49 accent_secondary: Option<String>,
50 accent_action: Option<String>,
51 error_fg: Option<String>,
52 error_hover: Option<String>,
53 error_surface: Option<String>,
54 error_border: Option<String>,
55 error_text: Option<String>,
56 warning: Option<String>,
57 success: Option<String>,
58 info: Option<String>,
59 mode_agent: Option<String>,
60 mode_yolo: Option<String>,
61 mode_plan: Option<String>,
62 mode_operate: Option<String>,
63 permission_ask: Option<String>,
64 permission_auto_review: Option<String>,
65 permission_full_access: Option<String>,
66 status_ready: Option<String>,
67 status_working: Option<String>,
68 status_warning: Option<String>,
69 diff_added_fg: Option<String>,
70 diff_deleted_fg: Option<String>,
71 diff_added_bg: Option<String>,
72 diff_deleted_bg: Option<String>,
73 tool_running: Option<String>,
74 tool_success: Option<String>,
75 tool_failed: Option<String>,
76 }
77
78 #[must_use]
79 pub fn user_theme_schema_json() -> &'static str {
80 USER_THEME_SCHEMA
81 }
82
83 pub fn normalize_user_theme_selector(value: &str) -> Result<Option<String>, String> {
84 let trimmed = value.trim();
85 let Some(slug) = trimmed.strip_prefix(USER_THEME_PREFIX) else {
86 return Ok(None);
87 };
88 let slug = slug.trim().to_ascii_lowercase();
89 if slug.is_empty()
90 || slug.len() > 64
91 || !slug
92 .chars()
93 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
94 {
95 return Err(
96 "custom theme names must be 1-64 ASCII letters, digits, '-' or '_'".to_string(),
97 );
98 }
99 Ok(Some(format!("{USER_THEME_PREFIX}{slug}")))
100 }
101
102 pub fn normalize_theme_setting(value: &str) -> Result<String, String> {
103 if let Some(id) = ThemeId::from_name(value) {
104 return Ok(id.name().to_string());
105 }
106 normalize_user_theme_selector(value)?.ok_or_else(|| {
107 format!("invalid theme '{value}'; use a compiled theme name or custom:<name>")
108 })
109 }
110
111 pub fn resolve_theme_setting(
112 value: &str,
113 background_color: Option<&str>,
114 ) -> Result<(String, ThemeId, UiTheme), String> {
115 let normalized = normalize_theme_setting(value)?;
116 let (id, mut theme) = if let Some(resolved) = resolve_user_theme(&normalized)? {
117 resolved
118 } else {
119 let id = ThemeId::from_name(&normalized)
120 .ok_or_else(|| format!("invalid compiled theme '{normalized}'"))?;
121 (id, id.ui_theme())
122 };
123 if let Some(value) = background_color {
124 theme = theme.with_background_color(color("background_color", value)?);
125 }
126 Ok((normalized, id, theme))
127 }
128
129 pub fn resolve_user_theme(value: &str) -> Result<Option<(ThemeId, UiTheme)>, String> {
130 let Some(selector) = normalize_user_theme_selector(value)? else {
131 return Ok(None);
132 };
133 let slug = selector.trim_start_matches(USER_THEME_PREFIX);
134 let themes_dir = user_themes_dir()?;
135 reject_symlink_directory(&themes_dir)?;
136 let path = themes_dir.join(format!("{slug}.json"));
137 let mut file = open_theme_file(&path)?;
138 let metadata = file
139 .metadata()
140 .map_err(|error| format!("failed to inspect user theme {}: {error}", path.display()))?;
141 if !metadata.is_file() {
142 return Err(format!(
143 "user theme {} must be a regular file",
144 path.display()
145 ));
146 }
147 if metadata.len() > MAX_USER_THEME_BYTES {
148 return Err(format!(
149 "user theme {} is too large ({} bytes; max {MAX_USER_THEME_BYTES})",
150 path.display(),
151 metadata.len()
152 ));
153 }
154 let mut raw = String::with_capacity(metadata.len() as usize);
155 file.read_to_string(&mut raw)
156 .map_err(|error| format!("failed to read user theme {}: {error}", path.display()))?;
157 let parsed: UserThemeFile = serde_json::from_str(&raw)
158 .map_err(|error| format!("invalid user theme {}: {error}", path.display()))?;
159 if parsed.schema_version != 1 {
160 return Err(format!(
161 "unsupported user theme schema_version {} in {}; expected 1",
162 parsed.schema_version,
163 path.display()
164 ));
165 }
166 let base = ThemeId::from_name(&parsed.base).ok_or_else(|| {
167 format!(
168 "invalid base theme '{}' in {}; use a compiled theme name",
169 parsed.base,
170 path.display()
171 )
172 })?;
173 let mut theme = base.ui_theme();
174 apply_colors(&mut theme, &parsed.colors)?;
175 Ok(Some((base, theme)))
176 }
177
178 /// List valid user-authored theme overlays in stable selector order.
179 ///
180 /// Invalid, unreadable, oversized, and symlinked entries are omitted so an
181 /// optional malformed overlay cannot prevent the built-in picker from opening.
182 /// Detailed validation remains centralized in [`resolve_user_theme`].
183 #[must_use]
184 pub fn list_user_theme_options() -> Vec<UserThemeOption> {
185 let Ok(themes_dir) = user_themes_dir() else {
186 return Vec::new();
187 };
188 let Ok(metadata) = fs::symlink_metadata(&themes_dir) else {
189 return Vec::new();
190 };
191 if metadata.file_type().is_symlink() || !metadata.is_dir() {
192 return Vec::new();
193 }
194 let Ok(entries) = fs::read_dir(&themes_dir) else {
195 return Vec::new();
196 };
197
198 let mut options = entries
199 .filter_map(Result::ok)
200 .filter_map(|entry| {
201 let path = entry.path();
202 (path.extension().and_then(|extension| extension.to_str()) == Some("json"))
203 .then(|| path.file_stem()?.to_str().map(str::to_string))
204 .flatten()
205 })
206 .filter_map(|slug| {
207 let selector = normalize_user_theme_selector(&format!("{USER_THEME_PREFIX}{slug}"))
208 .ok()
209 .flatten()?;
210 let (base, theme) = resolve_user_theme(&selector).ok().flatten()?;
211 Some(UserThemeOption {
212 selector,
213 base,
214 theme,
215 })
216 })
217 .collect::<Vec<_>>();
218 options.sort_by(|left, right| left.selector.cmp(&right.selector));
219 options.dedup_by(|left, right| left.selector == right.selector);
220 options
221 }
222
223 pub fn user_themes_dir() -> Result<PathBuf, String> {
224 codewhale_config::codewhale_home()
225 .map(|home| home.join("themes"))
226 .map_err(|error| format!("failed to resolve Codewhale themes directory: {error}"))
227 }
228
229 fn reject_symlink_directory(path: &Path) -> Result<(), String> {
230 let metadata = fs::symlink_metadata(path).map_err(|error| {
231 format!(
232 "failed to inspect themes directory {}: {error}",
233 path.display()
234 )
235 })?;
236 if metadata.file_type().is_symlink() || !metadata.is_dir() {
237 return Err(format!(
238 "themes directory {} must be a real directory, not a symlink",
239 path.display()
240 ));
241 }
242 Ok(())
243 }
244
245 fn open_theme_file(path: &Path) -> Result<File, String> {
246 let mut options = OpenOptions::new();
247 options.read(true);
248 #[cfg(unix)]
249 {
250 use std::os::unix::fs::OpenOptionsExt;
251 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
252 }
253 #[cfg(windows)]
254 {
255 use std::os::windows::fs::OpenOptionsExt;
256 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
257 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
258 }
259 options.open(path).map_err(|error| {
260 format!(
261 "failed to open user theme {} safely: {error}",
262 path.display()
263 )
264 })
265 }
266
267 fn color(name: &str, value: &str) -> Result<Color, String> {
268 parse_hex_rgb_color(value)
269 .ok_or_else(|| format!("user theme color '{name}' must be #RRGGBB, got '{value}'"))
270 }
271
272 fn apply_colors(theme: &mut UiTheme, colors: &UserThemeColors) -> Result<(), String> {
273 macro_rules! apply {
274 ($($field:ident),+ $(,)?) => {$({
275 if let Some(value) = colors.$field.as_deref() {
276 theme.$field = color(stringify!($field), value)?;
277 }
278 })+};
279 }
280 apply!(
281 surface_bg,
282 panel_bg,
283 elevated_bg,
284 composer_bg,
285 selection_bg,
286 header_bg,
287 footer_bg,
288 text_dim,
289 text_hint,
290 text_muted,
291 text_body,
292 text_soft,
293 border,
294 accent_primary,
295 accent_secondary,
296 accent_action,
297 error_fg,
298 error_hover,
299 error_surface,
300 error_border,
301 error_text,
302 warning,
303 success,
304 info,
305 mode_agent,
306 mode_yolo,
307 mode_plan,
308 mode_operate,
309 permission_ask,
310 permission_auto_review,
311 permission_full_access,
312 status_ready,
313 status_working,
314 status_warning,
315 diff_added_fg,
316 diff_deleted_fg,
317 diff_added_bg,
318 diff_deleted_bg,
319 tool_running,
320 tool_success,
321 tool_failed,
322 );
323 Ok(())
324 }
325
326 #[cfg(test)]
327 mod tests {
328 use super::*;
329 use std::ffi::{OsStr, OsString};
330 use std::sync::{Mutex, MutexGuard, OnceLock};
331
332 /// Serialise env-mutating tests: these poke `CODEWHALE_HOME`, which is
333 /// process-global. Same shape as `crates/secrets` and `crates/config`.
334 fn lock_test_env() -> MutexGuard<'static, ()> {
335 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
336 LOCK.get_or_init(|| Mutex::new(()))
337 .lock()
338 .unwrap_or_else(|poisoned| poisoned.into_inner())
339 }
340
341 /// Restore one environment variable when dropped. Callers hold
342 /// [`lock_test_env`] until after the guard drops.
343 struct EnvVarGuard {
344 key: &'static str,
345 previous: Option<OsString>,
346 }
347
348 impl EnvVarGuard {
349 fn set(key: &'static str, value: impl AsRef<OsStr>) -> Self {
350 let previous = std::env::var_os(key);
351 // SAFETY: callers hold the process-wide test env mutex.
352 unsafe { std::env::set_var(key, value) };
353 Self { key, previous }
354 }
355 }
356
357 impl Drop for EnvVarGuard {
358 fn drop(&mut self) {
359 // SAFETY: callers hold the process-wide test env mutex until after
360 // this guard is dropped.
361 unsafe {
362 match self.previous.take() {
363 Some(value) => std::env::set_var(self.key, value),
364 None => std::env::remove_var(self.key),
365 }
366 }
367 }
368 }
369
370 #[test]
371 fn selector_rejects_paths_and_accepts_bounded_slugs() {
372 assert_eq!(
373 normalize_user_theme_selector("custom:My_Theme").unwrap(),
374 Some("custom:my_theme".to_string())
375 );
376 assert!(normalize_user_theme_selector("custom:../secret").is_err());
377 assert!(normalize_user_theme_selector("custom:").is_err());
378 assert_eq!(normalize_user_theme_selector("dark").unwrap(), None);
379 }
380
381 #[test]
382 fn user_theme_loads_fixed_file_and_rejects_unknown_fields() {
383 let _lock = lock_test_env();
384 let temp = tempfile::tempdir().unwrap();
385 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
386 let themes = temp.path().join("themes");
387 fs::create_dir(&themes).unwrap();
388 fs::write(
389 themes.join("ocean.json"),
390 r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##,
391 )
392 .unwrap();
393 let (base, theme) = resolve_user_theme("custom:ocean").unwrap().unwrap();
394 assert_eq!(base, ThemeId::Whale);
395 assert_eq!(theme.accent_primary, Color::Rgb(0x12, 0x34, 0x56));
396
397 fs::write(
398 themes.join("bad.json"),
399 r##"{"schema_version":1,"base":"dark","colors":{"mystery":"#123456"}}"##,
400 )
401 .unwrap();
402 assert!(resolve_user_theme("custom:bad").is_err());
403 }
404
405 #[cfg(unix)]
406 #[test]
407 fn user_theme_refuses_symlink_files() {
408 use std::os::unix::fs::symlink;
409 let _lock = lock_test_env();
410 let temp = tempfile::tempdir().unwrap();
411 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
412 let themes = temp.path().join("themes");
413 fs::create_dir(&themes).unwrap();
414 let outside = temp.path().join("outside.json");
415 fs::write(&outside, "{}").unwrap();
416 symlink(&outside, themes.join("linked.json")).unwrap();
417 assert!(resolve_user_theme("custom:linked").is_err());
418 }
419
420 #[test]
421 fn list_user_theme_options_keeps_only_valid_sorted_overlays() {
422 let _lock = lock_test_env();
423 let temp = tempfile::tempdir().unwrap();
424 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
425 let themes = temp.path().join("themes");
426 fs::create_dir(&themes).unwrap();
427 let valid = r##"{"schema_version":1,"base":"dark","colors":{}}"##;
428 fs::write(themes.join("zulu.json"), valid).unwrap();
429 fs::write(themes.join("alpha.json"), valid).unwrap();
430 fs::write(themes.join("broken.json"), "not json").unwrap();
431 fs::write(themes.join("notes.txt"), valid).unwrap();
432
433 let options = list_user_theme_options();
434
435 assert_eq!(
436 options
437 .iter()
438 .map(|option| option.selector.as_str())
439 .collect::<Vec<_>>(),
440 ["custom:alpha", "custom:zulu"]
441 );
442 assert!(options.iter().all(|option| option.base == ThemeId::Whale));
443 }
444 }
445
445 lines RUST