1use std::collections::HashMap;
37use std::path::{Path, PathBuf};
38
39use opi_tui::theme::{Theme, is_valid_token, parse_color};
40use ratatui::style::Color;
41use serde::Deserialize;
42
43#[derive(Debug, thiserror::Error)]
49pub enum ThemeDiscoveryError {
50 #[error("invalid theme manifest at {path}: {reason}")]
52 InvalidManifest { path: PathBuf, reason: String },
53 #[error("missing required field '{field}' in theme at {path}")]
55 MissingField { field: String, path: PathBuf },
56 #[error("duplicate theme name '{name}' in discovery layer at {path}")]
58 DuplicateName { name: String, path: PathBuf },
59 #[error("invalid theme name in {path}: {reason}")]
61 InvalidName { path: PathBuf, reason: String },
62 #[error("invalid description in theme at {path}: {reason}")]
64 InvalidDescription { path: PathBuf, reason: String },
65 #[error("invalid color for token '{token}' in theme at {path}: {reason}")]
67 InvalidColor {
68 token: String,
69 path: PathBuf,
70 reason: String,
71 },
72 #[error("unknown color token '{token}' in theme at {path}")]
74 UnknownToken { token: String, path: PathBuf },
75 #[error("I/O error discovering themes: {0}")]
77 Io(#[from] std::io::Error),
78}
79
80const MAX_NAME_LEN: usize = 64;
86
87const MAX_DESCRIPTION_LEN: usize = 1024;
89
90#[derive(Debug, Clone, PartialEq)]
96pub struct ThemeManifest {
97 pub name: String,
100 pub description: String,
103}
104
105#[derive(Debug, Clone, Deserialize)]
107struct TomlThemeFile {
108 name: Option<String>,
109 description: Option<String>,
110 colors: Option<HashMap<String, String>>,
111}
112
113impl ThemeManifest {
114 pub fn from_toml(content: &str, path: &Path) -> Result<Self, ThemeDiscoveryError> {
119 let file: TomlThemeFile =
120 toml::from_str(content).map_err(|e| ThemeDiscoveryError::InvalidManifest {
121 path: path.to_path_buf(),
122 reason: e.to_string(),
123 })?;
124
125 let name = file.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
126 ThemeDiscoveryError::MissingField {
127 field: "name".into(),
128 path: path.to_path_buf(),
129 }
130 })?;
131
132 validate_theme_name(&name, path)?;
133
134 let description = file
135 .description
136 .filter(|d| !d.trim().is_empty())
137 .ok_or_else(|| ThemeDiscoveryError::MissingField {
138 field: "description".into(),
139 path: path.to_path_buf(),
140 })?;
141
142 validate_description(&description, path)?;
143
144 Ok(Self { name, description })
145 }
146}
147
148fn validate_theme_name(name: &str, path: &Path) -> Result<(), ThemeDiscoveryError> {
155 if name.len() > MAX_NAME_LEN {
156 return Err(ThemeDiscoveryError::InvalidName {
157 path: path.to_path_buf(),
158 reason: format!(
159 "name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
160 name.len()
161 ),
162 });
163 }
164
165 for ch in name.chars() {
166 let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
167 if !valid {
168 return Err(ThemeDiscoveryError::InvalidName {
169 path: path.to_path_buf(),
170 reason: format!(
171 "name contains invalid character '{ch}': \
172 only lowercase a-z, 0-9, and hyphens are allowed"
173 ),
174 });
175 }
176 }
177
178 Ok(())
179}
180
181fn validate_description(desc: &str, path: &Path) -> Result<(), ThemeDiscoveryError> {
183 if desc.len() > MAX_DESCRIPTION_LEN {
184 return Err(ThemeDiscoveryError::InvalidDescription {
185 path: path.to_path_buf(),
186 reason: format!(
187 "description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
188 ({} found)",
189 desc.len()
190 ),
191 });
192 }
193 Ok(())
194}
195
196#[derive(Debug, Clone)]
206pub struct ThemeResource {
207 pub manifest: ThemeManifest,
209 pub path: PathBuf,
211 pub theme_toml_path: PathBuf,
213 pub layer_precedence: u32,
215}
216
217impl ThemeResource {
218 pub fn load_theme(&self) -> Result<Theme, ThemeDiscoveryError> {
224 let content = std::fs::read_to_string(&self.theme_toml_path)?;
225 let file: TomlThemeFile =
226 toml::from_str(&content).map_err(|e| ThemeDiscoveryError::InvalidManifest {
227 path: self.theme_toml_path.clone(),
228 reason: e.to_string(),
229 })?;
230
231 let mut colors: HashMap<String, Color> = HashMap::new();
232 if let Some(raw_colors) = file.colors {
233 for (token, value) in &raw_colors {
234 if !is_valid_token(token) {
235 return Err(ThemeDiscoveryError::UnknownToken {
236 token: token.clone(),
237 path: self.theme_toml_path.clone(),
238 });
239 }
240
241 let color = parse_color(value).map_err(|e| ThemeDiscoveryError::InvalidColor {
242 token: token.clone(),
243 path: self.theme_toml_path.clone(),
244 reason: e.to_string(),
245 })?;
246
247 colors.insert(token.clone(), color);
248 }
249 }
250
251 Theme::from_color_map(self.manifest.name.clone(), &colors).map_err(|e| {
252 ThemeDiscoveryError::InvalidColor {
253 token: String::new(),
254 path: self.theme_toml_path.clone(),
255 reason: e.to_string(),
256 }
257 })
258 }
259}
260
261pub fn discover_themes(
276 layers: &[crate::resource::DiscoveryLayer],
277) -> Result<Vec<ThemeResource>, ThemeDiscoveryError> {
278 let mut seen: HashMap<String, ThemeResource> = HashMap::new();
279
280 for layer in layers {
281 let scan_dir = layer.scan_dir();
282 if !scan_dir.is_dir() {
283 continue;
284 }
285
286 if scan_dir.join("theme.toml").exists() {
287 discover_theme_dir(&scan_dir, layer, &mut seen)?;
288 continue;
289 }
290
291 let entries = match std::fs::read_dir(&scan_dir) {
292 Ok(entries) => entries,
293 Err(e) => return Err(ThemeDiscoveryError::Io(e)),
294 };
295
296 for entry in entries {
297 let entry = entry?;
298 let path = entry.path();
299
300 if !path.is_dir() {
301 continue;
302 }
303
304 let theme_toml = path.join("theme.toml");
305 if !theme_toml.exists() {
306 continue;
307 }
308
309 discover_theme_dir(&path, layer, &mut seen)?;
310 }
311 }
312
313 let mut resources: Vec<ThemeResource> = seen.into_values().collect();
314 resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
315 Ok(resources)
316}
317
318fn discover_theme_dir(
319 path: &Path,
320 layer: &crate::resource::DiscoveryLayer,
321 seen: &mut HashMap<String, ThemeResource>,
322) -> Result<(), ThemeDiscoveryError> {
323 let theme_toml = path.join("theme.toml");
324 let content = std::fs::read_to_string(&theme_toml)?;
325 let manifest = ThemeManifest::from_toml(&content, &theme_toml)?;
326
327 let canonical = path.canonicalize()?;
328
329 match seen.get(&manifest.name) {
330 Some(existing) if layer.precedence == existing.layer_precedence => {
331 return Err(ThemeDiscoveryError::DuplicateName {
332 name: manifest.name,
333 path: canonical,
334 });
335 }
336 Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
337 Some(_) | None => {
338 seen.insert(
339 manifest.name.clone(),
340 ThemeResource {
341 manifest,
342 path: canonical,
343 theme_toml_path: theme_toml,
344 layer_precedence: layer.precedence,
345 },
346 );
347 }
348 }
349
350 Ok(())
351}
352
353pub struct ThemeRegistry {
360 resources: Vec<ThemeResource>,
361}
362
363impl ThemeRegistry {
364 pub fn from_resources(resources: Vec<ThemeResource>) -> Self {
366 Self { resources }
367 }
368
369 pub fn names(&self) -> Vec<&str> {
371 self.resources
372 .iter()
373 .map(|r| r.manifest.name.as_str())
374 .collect()
375 }
376
377 pub fn get(&self, name: &str) -> Option<&ThemeResource> {
379 self.resources.iter().find(|r| r.manifest.name == name)
380 }
381
382 pub fn load_theme(&self, name: &str) -> Option<Result<Theme, ThemeDiscoveryError>> {
387 self.get(name).map(|r| r.load_theme())
388 }
389
390 pub fn resolve_theme(&self, name: &str) -> Result<Theme, ThemeDiscoveryError> {
393 if let Some(result) = self.load_theme(name) {
395 return result;
396 }
397
398 Ok(opi_tui::theme::resolve_theme(name))
400 }
401
402 pub fn format_for_prompt(&self) -> String {
405 if self.resources.is_empty() {
406 return String::new();
407 }
408
409 let parts: Vec<String> = self
410 .resources
411 .iter()
412 .map(|r| format!("- {}: {}", r.manifest.name, r.manifest.description))
413 .collect();
414 parts.join("\n")
415 }
416}