use std::collections::HashMap;
use std::path::{Path, PathBuf};
use opi_tui::theme::{Theme, is_valid_token, parse_color};
use ratatui::style::Color;
use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
pub enum ThemeDiscoveryError {
#[error("invalid theme manifest at {path}: {reason}")]
InvalidManifest { path: PathBuf, reason: String },
#[error("missing required field '{field}' in theme at {path}")]
MissingField { field: String, path: PathBuf },
#[error("duplicate theme name '{name}' in discovery layer at {path}")]
DuplicateName { name: String, path: PathBuf },
#[error("invalid theme name in {path}: {reason}")]
InvalidName { path: PathBuf, reason: String },
#[error("invalid description in theme at {path}: {reason}")]
InvalidDescription { path: PathBuf, reason: String },
#[error("invalid color for token '{token}' in theme at {path}: {reason}")]
InvalidColor {
token: String,
path: PathBuf,
reason: String,
},
#[error("unknown color token '{token}' in theme at {path}")]
UnknownToken { token: String, path: PathBuf },
#[error("I/O error discovering themes: {0}")]
Io(#[from] std::io::Error),
}
const MAX_NAME_LEN: usize = 64;
const MAX_DESCRIPTION_LEN: usize = 1024;
#[derive(Debug, Clone, PartialEq)]
pub struct ThemeManifest {
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct TomlThemeFile {
name: Option<String>,
description: Option<String>,
colors: Option<HashMap<String, String>>,
}
impl ThemeManifest {
pub fn from_toml(content: &str, path: &Path) -> Result<Self, ThemeDiscoveryError> {
let file: TomlThemeFile =
toml::from_str(content).map_err(|e| ThemeDiscoveryError::InvalidManifest {
path: path.to_path_buf(),
reason: e.to_string(),
})?;
let name = file.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
ThemeDiscoveryError::MissingField {
field: "name".into(),
path: path.to_path_buf(),
}
})?;
validate_theme_name(&name, path)?;
let description = file
.description
.filter(|d| !d.trim().is_empty())
.ok_or_else(|| ThemeDiscoveryError::MissingField {
field: "description".into(),
path: path.to_path_buf(),
})?;
validate_description(&description, path)?;
Ok(Self { name, description })
}
}
fn validate_theme_name(name: &str, path: &Path) -> Result<(), ThemeDiscoveryError> {
if name.len() > MAX_NAME_LEN {
return Err(ThemeDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
name.len()
),
});
}
for ch in name.chars() {
let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
if !valid {
return Err(ThemeDiscoveryError::InvalidName {
path: path.to_path_buf(),
reason: format!(
"name contains invalid character '{ch}': \
only lowercase a-z, 0-9, and hyphens are allowed"
),
});
}
}
Ok(())
}
fn validate_description(desc: &str, path: &Path) -> Result<(), ThemeDiscoveryError> {
if desc.len() > MAX_DESCRIPTION_LEN {
return Err(ThemeDiscoveryError::InvalidDescription {
path: path.to_path_buf(),
reason: format!(
"description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
({} found)",
desc.len()
),
});
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ThemeResource {
pub manifest: ThemeManifest,
pub path: PathBuf,
pub theme_toml_path: PathBuf,
pub layer_precedence: u32,
}
impl ThemeResource {
pub fn load_theme(&self) -> Result<Theme, ThemeDiscoveryError> {
let content = std::fs::read_to_string(&self.theme_toml_path)?;
let file: TomlThemeFile =
toml::from_str(&content).map_err(|e| ThemeDiscoveryError::InvalidManifest {
path: self.theme_toml_path.clone(),
reason: e.to_string(),
})?;
let mut colors: HashMap<String, Color> = HashMap::new();
if let Some(raw_colors) = file.colors {
for (token, value) in &raw_colors {
if !is_valid_token(token) {
return Err(ThemeDiscoveryError::UnknownToken {
token: token.clone(),
path: self.theme_toml_path.clone(),
});
}
let color = parse_color(value).map_err(|e| ThemeDiscoveryError::InvalidColor {
token: token.clone(),
path: self.theme_toml_path.clone(),
reason: e.to_string(),
})?;
colors.insert(token.clone(), color);
}
}
Theme::from_color_map(self.manifest.name.clone(), &colors).map_err(|e| {
ThemeDiscoveryError::InvalidColor {
token: String::new(),
path: self.theme_toml_path.clone(),
reason: e.to_string(),
}
})
}
}
pub fn discover_themes(
layers: &[crate::resource::DiscoveryLayer],
) -> Result<Vec<ThemeResource>, ThemeDiscoveryError> {
let mut seen: HashMap<String, ThemeResource> = HashMap::new();
for layer in layers {
let scan_dir = layer.scan_dir();
if !scan_dir.is_dir() {
continue;
}
if scan_dir.join("theme.toml").exists() {
discover_theme_dir(&scan_dir, layer, &mut seen)?;
continue;
}
let entries = match std::fs::read_dir(&scan_dir) {
Ok(entries) => entries,
Err(e) => return Err(ThemeDiscoveryError::Io(e)),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let theme_toml = path.join("theme.toml");
if !theme_toml.exists() {
continue;
}
discover_theme_dir(&path, layer, &mut seen)?;
}
}
let mut resources: Vec<ThemeResource> = seen.into_values().collect();
resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
Ok(resources)
}
fn discover_theme_dir(
path: &Path,
layer: &crate::resource::DiscoveryLayer,
seen: &mut HashMap<String, ThemeResource>,
) -> Result<(), ThemeDiscoveryError> {
let theme_toml = path.join("theme.toml");
let content = std::fs::read_to_string(&theme_toml)?;
let manifest = ThemeManifest::from_toml(&content, &theme_toml)?;
let canonical = path.canonicalize()?;
match seen.get(&manifest.name) {
Some(existing) if layer.precedence == existing.layer_precedence => {
return Err(ThemeDiscoveryError::DuplicateName {
name: manifest.name,
path: canonical,
});
}
Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
Some(_) | None => {
seen.insert(
manifest.name.clone(),
ThemeResource {
manifest,
path: canonical,
theme_toml_path: theme_toml,
layer_precedence: layer.precedence,
},
);
}
}
Ok(())
}
pub struct ThemeRegistry {
resources: Vec<ThemeResource>,
}
impl ThemeRegistry {
pub fn from_resources(resources: Vec<ThemeResource>) -> Self {
Self { resources }
}
pub fn names(&self) -> Vec<&str> {
self.resources
.iter()
.map(|r| r.manifest.name.as_str())
.collect()
}
pub fn get(&self, name: &str) -> Option<&ThemeResource> {
self.resources.iter().find(|r| r.manifest.name == name)
}
pub fn load_theme(&self, name: &str) -> Option<Result<Theme, ThemeDiscoveryError>> {
self.get(name).map(|r| r.load_theme())
}
pub fn resolve_theme(&self, name: &str) -> Result<Theme, ThemeDiscoveryError> {
if let Some(result) = self.load_theme(name) {
return result;
}
Ok(opi_tui::theme::resolve_theme(name))
}
pub fn format_for_prompt(&self) -> String {
if self.resources.is_empty() {
return String::new();
}
let parts: Vec<String> = self
.resources
.iter()
.map(|r| format!("- {}: {}", r.manifest.name, r.manifest.description))
.collect();
parts.join("\n")
}
}