返回 CodeWhale
user_theme.rs
根目录 / crates / tui / src / palette / 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 #[derive(Debug, Deserialize)]
17 #[serde(deny_unknown_fields)]
18 struct UserThemeFile {
19 schema_version: u8,
20 base: String,
21 colors: UserThemeColors,
22 }
23
24 #[derive(Debug, Default, Deserialize)]
25 #[serde(default, deny_unknown_fields)]
26 struct UserThemeColors {
27 surface_bg: Option<String>,
28 panel_bg: Option<String>,
29 elevated_bg: Option<String>,
30 composer_bg: Option<String>,
31 selection_bg: Option<String>,
32 header_bg: Option<String>,
33 footer_bg: Option<String>,
34 text_dim: Option<String>,
35 text_hint: Option<String>,
36 text_muted: Option<String>,
37 text_body: Option<String>,
38 text_soft: Option<String>,
39 border: Option<String>,
40 accent_primary: Option<String>,
41 accent_secondary: Option<String>,
42 accent_action: Option<String>,
43 error_fg: Option<String>,
44 error_hover: Option<String>,
45 error_surface: Option<String>,
46 error_border: Option<String>,
47 error_text: Option<String>,
48 warning: Option<String>,
49 success: Option<String>,
50 info: Option<String>,
51 mode_agent: Option<String>,
52 mode_yolo: Option<String>,
53 mode_plan: Option<String>,
54 mode_operate: Option<String>,
55 permission_ask: Option<String>,
56 permission_auto_review: Option<String>,
57 permission_full_access: Option<String>,
58 status_ready: Option<String>,
59 status_working: Option<String>,
60 status_warning: Option<String>,
61 diff_added_fg: Option<String>,
62 diff_deleted_fg: Option<String>,
63 diff_added_bg: Option<String>,
64 diff_deleted_bg: Option<String>,
65 tool_running: Option<String>,
66 tool_success: Option<String>,
67 tool_failed: Option<String>,
68 }
69
70 #[must_use]
71 pub fn user_theme_schema_json() -> &'static str {
72 USER_THEME_SCHEMA
73 }
74
75 pub fn normalize_user_theme_selector(value: &str) -> Result<Option<String>, String> {
76 let trimmed = value.trim();
77 let Some(slug) = trimmed.strip_prefix(USER_THEME_PREFIX) else {
78 return Ok(None);
79 };
80 let slug = slug.trim().to_ascii_lowercase();
81 if slug.is_empty()
82 || slug.len() > 64
83 || !slug
84 .chars()
85 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
86 {
87 return Err(
88 "custom theme names must be 1-64 ASCII letters, digits, '-' or '_'".to_string(),
89 );
90 }
91 Ok(Some(format!("{USER_THEME_PREFIX}{slug}")))
92 }
93
94 pub fn normalize_theme_setting(value: &str) -> Result<String, String> {
95 if let Some(id) = ThemeId::from_name(value) {
96 return Ok(id.name().to_string());
97 }
98 normalize_user_theme_selector(value)?.ok_or_else(|| {
99 format!("invalid theme '{value}'; use a compiled theme name or custom:<name>")
100 })
101 }
102
103 pub fn resolve_theme_setting(
104 value: &str,
105 background_color: Option<&str>,
106 ) -> Result<(String, ThemeId, UiTheme), String> {
107 let normalized = normalize_theme_setting(value)?;
108 let (id, mut theme) = if let Some(resolved) = resolve_user_theme(&normalized)? {
109 resolved
110 } else {
111 let id = ThemeId::from_name(&normalized)
112 .ok_or_else(|| format!("invalid compiled theme '{normalized}'"))?;
113 (id, id.ui_theme())
114 };
115 if let Some(value) = background_color {
116 theme = theme.with_background_color(color("background_color", value)?);
117 }
118 Ok((normalized, id, theme))
119 }
120
121 pub fn resolve_user_theme(value: &str) -> Result<Option<(ThemeId, UiTheme)>, String> {
122 let Some(selector) = normalize_user_theme_selector(value)? else {
123 return Ok(None);
124 };
125 let slug = selector.trim_start_matches(USER_THEME_PREFIX);
126 let themes_dir = user_themes_dir()?;
127 reject_symlink_directory(&themes_dir)?;
128 let path = themes_dir.join(format!("{slug}.json"));
129 let mut file = open_theme_file(&path)?;
130 let metadata = file
131 .metadata()
132 .map_err(|error| format!("failed to inspect user theme {}: {error}", path.display()))?;
133 if !metadata.is_file() {
134 return Err(format!(
135 "user theme {} must be a regular file",
136 path.display()
137 ));
138 }
139 if metadata.len() > MAX_USER_THEME_BYTES {
140 return Err(format!(
141 "user theme {} is too large ({} bytes; max {MAX_USER_THEME_BYTES})",
142 path.display(),
143 metadata.len()
144 ));
145 }
146 let mut raw = String::with_capacity(metadata.len() as usize);
147 file.read_to_string(&mut raw)
148 .map_err(|error| format!("failed to read user theme {}: {error}", path.display()))?;
149 let parsed: UserThemeFile = serde_json::from_str(&raw)
150 .map_err(|error| format!("invalid user theme {}: {error}", path.display()))?;
151 if parsed.schema_version != 1 {
152 return Err(format!(
153 "unsupported user theme schema_version {} in {}; expected 1",
154 parsed.schema_version,
155 path.display()
156 ));
157 }
158 let base = ThemeId::from_name(&parsed.base).ok_or_else(|| {
159 format!(
160 "invalid base theme '{}' in {}; use a compiled theme name",
161 parsed.base,
162 path.display()
163 )
164 })?;
165 let mut theme = base.ui_theme();
166 apply_colors(&mut theme, &parsed.colors)?;
167 Ok(Some((base, theme)))
168 }
169
170 pub fn user_themes_dir() -> Result<PathBuf, String> {
171 codewhale_config::codewhale_home()
172 .map(|home| home.join("themes"))
173 .map_err(|error| format!("failed to resolve Codewhale themes directory: {error}"))
174 }
175
176 fn reject_symlink_directory(path: &Path) -> Result<(), String> {
177 let metadata = fs::symlink_metadata(path).map_err(|error| {
178 format!(
179 "failed to inspect themes directory {}: {error}",
180 path.display()
181 )
182 })?;
183 if metadata.file_type().is_symlink() || !metadata.is_dir() {
184 return Err(format!(
185 "themes directory {} must be a real directory, not a symlink",
186 path.display()
187 ));
188 }
189 Ok(())
190 }
191
192 fn open_theme_file(path: &Path) -> Result<File, String> {
193 let mut options = OpenOptions::new();
194 options.read(true);
195 #[cfg(unix)]
196 {
197 use std::os::unix::fs::OpenOptionsExt;
198 options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
199 }
200 #[cfg(windows)]
201 {
202 use std::os::windows::fs::OpenOptionsExt;
203 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
204 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
205 }
206 options.open(path).map_err(|error| {
207 format!(
208 "failed to open user theme {} safely: {error}",
209 path.display()
210 )
211 })
212 }
213
214 fn color(name: &str, value: &str) -> Result<Color, String> {
215 parse_hex_rgb_color(value)
216 .ok_or_else(|| format!("user theme color '{name}' must be #RRGGBB, got '{value}'"))
217 }
218
219 fn apply_colors(theme: &mut UiTheme, colors: &UserThemeColors) -> Result<(), String> {
220 macro_rules! apply {
221 ($($field:ident),+ $(,)?) => {$({
222 if let Some(value) = colors.$field.as_deref() {
223 theme.$field = color(stringify!($field), value)?;
224 }
225 })+};
226 }
227 apply!(
228 surface_bg,
229 panel_bg,
230 elevated_bg,
231 composer_bg,
232 selection_bg,
233 header_bg,
234 footer_bg,
235 text_dim,
236 text_hint,
237 text_muted,
238 text_body,
239 text_soft,
240 border,
241 accent_primary,
242 accent_secondary,
243 accent_action,
244 error_fg,
245 error_hover,
246 error_surface,
247 error_border,
248 error_text,
249 warning,
250 success,
251 info,
252 mode_agent,
253 mode_yolo,
254 mode_plan,
255 mode_operate,
256 permission_ask,
257 permission_auto_review,
258 permission_full_access,
259 status_ready,
260 status_working,
261 status_warning,
262 diff_added_fg,
263 diff_deleted_fg,
264 diff_added_bg,
265 diff_deleted_bg,
266 tool_running,
267 tool_success,
268 tool_failed,
269 );
270 Ok(())
271 }
272
273 #[cfg(test)]
274 mod tests {
275 use super::*;
276 use crate::test_support::EnvVarGuard;
277
278 #[test]
279 fn selector_rejects_paths_and_accepts_bounded_slugs() {
280 assert_eq!(
281 normalize_user_theme_selector("custom:My_Theme").unwrap(),
282 Some("custom:my_theme".to_string())
283 );
284 assert!(normalize_user_theme_selector("custom:../secret").is_err());
285 assert!(normalize_user_theme_selector("custom:").is_err());
286 assert_eq!(normalize_user_theme_selector("dark").unwrap(), None);
287 }
288
289 #[test]
290 fn user_theme_loads_fixed_file_and_rejects_unknown_fields() {
291 let _lock = crate::test_support::lock_test_env();
292 let temp = tempfile::tempdir().unwrap();
293 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
294 let themes = temp.path().join("themes");
295 fs::create_dir(&themes).unwrap();
296 fs::write(
297 themes.join("ocean.json"),
298 r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##,
299 )
300 .unwrap();
301 let (base, theme) = resolve_user_theme("custom:ocean").unwrap().unwrap();
302 assert_eq!(base, ThemeId::Whale);
303 assert_eq!(theme.accent_primary, Color::Rgb(0x12, 0x34, 0x56));
304
305 fs::write(
306 themes.join("bad.json"),
307 r##"{"schema_version":1,"base":"dark","colors":{"mystery":"#123456"}}"##,
308 )
309 .unwrap();
310 assert!(resolve_user_theme("custom:bad").is_err());
311 }
312
313 #[cfg(unix)]
314 #[test]
315 fn user_theme_refuses_symlink_files() {
316 use std::os::unix::fs::symlink;
317 let _lock = crate::test_support::lock_test_env();
318 let temp = tempfile::tempdir().unwrap();
319 let _home = EnvVarGuard::set("CODEWHALE_HOME", temp.path());
320 let themes = temp.path().join("themes");
321 fs::create_dir(&themes).unwrap();
322 let outside = temp.path().join("outside.json");
323 fs::write(&outside, "{}").unwrap();
324 symlink(&outside, themes.join("linked.json")).unwrap();
325 assert!(resolve_user_theme("custom:linked").is_err());
326 }
327 }
328
328 lines RUST