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