use crate::{
COLOR_SECTIONS, Emphasis, Rgb, STEP_FLOOR, SemanticTokens, ThemeColors, ThemeMeta,
find_theme_path, resolve, tonal, wcag_contrast,
};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[allow(unused_imports)]
use crate::ansi_intent;
pub fn validate_theme_id(id: &str) -> Result<(), String> {
if !id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(format!("Invalid theme ID: {id}"));
}
Ok(())
}
pub fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
let meta = table.get("meta").and_then(|m| m.as_table());
let name = meta
.and_then(|m| m.get("name"))
.and_then(|v| v.as_str())
.unwrap_or(id)
.to_string();
let variant = meta
.and_then(|m| m.get("variant"))
.and_then(|v| v.as_str())
.unwrap_or("dark")
.to_string();
ThemeMeta {
id: id.to_string(),
name,
variant,
is_custom,
}
}
pub fn extract_colors(table: &toml::Table) -> HashMap<String, String> {
let mut colors = HashMap::new();
for section in COLOR_SECTIONS {
if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
for (key, val) in sect {
if let Some(color) = val.as_str() {
colors.insert(format!("{section}.{key}"), color.to_string());
}
}
}
}
derive_tonal_steps(&mut colors);
colors
}
pub fn derive_tonal_steps<S: std::hash::BuildHasher>(colors: &mut HashMap<String, String, S>) {
let ink = colors.get("content.primary").and_then(|v| Rgb::from_hex(v));
let page = colors.get("surface.page").and_then(|v| Rgb::from_hex(v));
let (Some(ink), Some(page)) = (ink, page) else {
return;
};
let mut reached = 0.0;
for (key, step) in [
("content.secondary", Emphasis::Secondary),
("content.muted", Emphasis::Muted),
] {
let (color, ratio) = step_clearing_floor(ink, page, step.ratio().max(reached));
reached = ratio;
colors.insert(key.to_string(), color.to_hex());
}
}
fn step_clearing_floor(ink: Rgb, page: Rgb, from: f32) -> (Rgb, f32) {
const PROBE: f32 = 0.005;
let mut ratio = from.clamp(0.0, 1.0);
loop {
let color = tonal(ink, page, ratio);
if wcag_contrast(color, ink) >= STEP_FLOOR || ratio >= 1.0 {
return (color, ratio);
}
ratio = (ratio + PROBE).min(1.0);
}
}
pub fn list_themes_from_dirs(dirs: &[(PathBuf, bool)]) -> Vec<ThemeMeta> {
let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
for (dir, is_custom) in dirs {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let table: toml::Table = match content.parse() {
Ok(t) => t,
Err(_) => continue,
};
seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
}
}
let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
themes.sort_by(|a, b| a.name.cmp(&b.name));
themes
}
pub fn parse_theme_str(id: &str, content: &str, is_custom: bool) -> Result<ThemeColors, String> {
validate_theme_id(id)?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("Failed to parse theme '{id}': {e}"))?;
let meta = parse_meta(id, &table, is_custom);
let colors = extract_colors(&table);
Ok(ThemeColors { meta, colors })
}
pub fn load_theme(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemeColors, String> {
validate_theme_id(id)?;
let (path, is_custom) =
find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
let meta = parse_meta(id, &table, is_custom);
let colors = extract_colors(&table);
Ok(ThemeColors { meta, colors })
}
pub fn load_semantic(dirs: &[(PathBuf, bool)], id: &str) -> Result<SemanticTokens, String> {
Ok(resolve(&load_theme(dirs, id)?))
}
pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, String> {
let content = std::fs::read_to_string(source_path)
.map_err(|e| format!("Failed to read {}: {}", source_path.display(), e))?;
let table: toml::Table = content.parse().map_err(|e| format!("Invalid TOML: {e}"))?;
let has_colors = COLOR_SECTIONS
.iter()
.any(|s| table.get(*s).and_then(|v| v.as_table()).is_some());
if !has_colors {
return Err(format!(
"Theme file must have at least one color section ({})",
COLOR_SECTIONS.join(", ")
));
}
let id = source_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or("Invalid file name")?
.to_string();
validate_theme_id(&id)?;
std::fs::create_dir_all(custom_dir)
.map_err(|e| format!("Failed to create {}: {}", custom_dir.display(), e))?;
let dest = custom_dir.join(format!("{id}.toml"));
std::fs::copy(source_path, &dest).map_err(|e| format!("Failed to copy theme: {e}"))?;
Ok(parse_meta(&id, &table, true))
}
pub fn delete_theme(custom_dir: &Path, id: &str) -> Result<(), String> {
validate_theme_id(id)?;
let path = custom_dir.join(format!("{id}.toml"));
if !path.is_file() {
return Err(format!("Custom theme '{id}' not found"));
}
std::fs::remove_file(&path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemePreview {
pub meta: ThemeMeta,
pub background: Option<String>,
pub foreground: Option<String>,
pub accent: Option<String>,
pub border: Option<String>,
}
fn color_at(table: &toml::Table, section: &str, key: &str) -> Option<String> {
table
.get(section)
.and_then(|s| s.as_table())
.and_then(|s| s.get(key))
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
}
pub fn load_theme_preview(dirs: &[(PathBuf, bool)], id: &str) -> Result<ThemePreview, String> {
validate_theme_id(id)?;
let (path, is_custom) =
find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let table: toml::Table = content
.parse()
.map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
Ok(ThemePreview {
meta: parse_meta(id, &table, is_custom),
background: color_at(&table, "surface", "page"),
foreground: color_at(&table, "content", "primary"),
accent: color_at(&table, "action", "primary"),
border: color_at(&table, "line", "border"),
})
}
pub fn export_theme(dirs: &[(PathBuf, bool)], id: &str, dest_path: &Path) -> Result<(), String> {
validate_theme_id(id)?;
let (source, _) = find_theme_path(dirs, id).ok_or_else(|| format!("Theme '{id}' not found"))?;
std::fs::copy(&source, dest_path).map_err(|e| format!("Failed to export theme: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixture::nord_toml;
use crate::{bundled_themes_dir, embedded_themes};
use std::fs;
#[test]
fn validate_theme_id_alphanumeric() {
assert!(validate_theme_id("darkmode").is_ok());
assert!(validate_theme_id("Theme123").is_ok());
}
#[test]
fn validate_theme_id_hyphens_underscores() {
assert!(validate_theme_id("dark-mode").is_ok());
assert!(validate_theme_id("my_theme_v2").is_ok());
}
#[test]
fn validate_theme_id_rejects_path_traversal() {
assert!(validate_theme_id("../etc/passwd").is_err());
assert!(validate_theme_id("foo/bar").is_err());
assert!(validate_theme_id("theme.toml").is_err());
}
#[test]
fn parse_meta_with_name_and_variant() {
let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
.parse()
.unwrap();
let meta = parse_meta("nord", &table, false);
assert_eq!(meta.id, "nord");
assert_eq!(meta.name, "Nord");
assert_eq!(meta.variant, "light");
assert!(!meta.is_custom);
}
#[test]
fn parse_meta_defaults_to_id_and_dark() {
let table: toml::Table = "".parse().unwrap();
let meta = parse_meta("fallback", &table, true);
assert_eq!(meta.name, "fallback");
assert_eq!(meta.variant, "dark");
assert!(meta.is_custom);
}
#[test]
fn extract_colors_reads_intent_sections() {
let table: toml::Table = nord_toml().parse().unwrap();
let colors = extract_colors(&table);
assert_eq!(colors.get("surface.page").unwrap(), "#2e3440");
assert_eq!(colors.get("content.primary").unwrap(), "#d8dee9");
assert_eq!(colors.get("action.primary").unwrap(), "#81a1c1");
assert_eq!(colors.get("status.danger").unwrap(), "#bf616a");
assert_eq!(colors.get("line.border").unwrap(), "#4c566a");
assert_eq!(colors.get("category.five").unwrap(), "#b48ead");
assert_eq!(colors.len(), 19);
}
#[test]
fn every_shipped_theme_ramps_one_way() {
for (id, toml) in embedded_themes() {
let theme = parse_theme_str(id, toml, false).unwrap();
let t = resolve(&theme);
let page = Rgb::from_hex(t.hex("surface-page").unwrap()).unwrap();
let steps = ["content", "content-secondary", "content-muted"]
.map(|k| wcag_contrast(Rgb::from_hex(t.hex(k).unwrap()).unwrap(), page));
assert!(
steps[0] > steps[1] && steps[1] > steps[2],
"{id}: emphasis does not fall monotonically: {steps:?}"
);
}
}
#[test]
fn every_shipped_theme_takes_a_visible_first_step() {
for (id, toml) in embedded_themes() {
let theme = parse_theme_str(id, toml, false).unwrap();
let t = resolve(&theme);
let ink = Rgb::from_hex(t.hex("content").unwrap()).unwrap();
let secondary = Rgb::from_hex(t.hex("content-secondary").unwrap()).unwrap();
let step = wcag_contrast(ink, secondary);
assert!(
step >= STEP_FLOOR,
"{id}: secondary is {step:.2} from its ink, under the {STEP_FLOOR} floor"
);
}
}
#[test]
fn an_authored_emphasis_step_does_not_survive_loading() {
let theme = parse_theme_str("nord", nord_toml(), false).unwrap();
assert_ne!(theme.colors.get("content.muted").unwrap(), "#616e88");
assert_ne!(theme.colors.get("content.secondary").unwrap(), "#e5e9f0");
}
#[test]
fn a_theme_with_no_page_keeps_what_it_authored() {
let mut colors = HashMap::new();
colors.insert("content.primary".to_string(), "#d8dee9".to_string());
colors.insert("content.muted".to_string(), "#616e88".to_string());
derive_tonal_steps(&mut colors);
assert_eq!(colors.get("content.muted").unwrap(), "#616e88");
}
#[test]
fn load_and_resolve_round_trip() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
let dirs = vec![(dir.path().to_path_buf(), false)];
let t = load_semantic(&dirs, "nord").unwrap();
assert_eq!(t.meta.name, "Nord");
assert_eq!(t.hex("action"), Some("#81a1c1"));
}
#[test]
fn load_theme_rejects_invalid_id() {
assert!(load_theme(&[], "../evil").is_err());
}
#[test]
fn list_themes_from_dirs_finds_toml_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("t.toml"), "[meta]\nname = \"T\"\n").unwrap();
fs::write(dir.path().join("x.txt"), "ignored").unwrap();
let dirs = vec![(dir.path().to_path_buf(), false)];
let themes = list_themes_from_dirs(&dirs);
assert_eq!(themes.len(), 1);
assert_eq!(themes[0].id, "t");
}
#[test]
fn import_theme_valid_and_rejects_empty() {
let src_dir = tempfile::tempdir().unwrap();
let custom_dir = tempfile::tempdir().unwrap();
let good = src_dir.path().join("my-theme.toml");
fs::write(&good, "[surface]\npage = \"#1a1b26\"\n").unwrap();
let meta = import_theme(&good, custom_dir.path()).unwrap();
assert_eq!(meta.id, "my-theme");
assert!(custom_dir.path().join("my-theme.toml").exists());
let empty = src_dir.path().join("empty.toml");
fs::write(&empty, "[meta]\nname = \"E\"\n").unwrap();
assert!(import_theme(&empty, custom_dir.path()).is_err());
}
#[test]
fn import_theme_rejects_invalid_toml() {
let src_dir = tempfile::tempdir().unwrap();
let custom_dir = tempfile::tempdir().unwrap();
let src = src_dir.path().join("bad.toml");
fs::write(&src, "this is not [valid toml [[[").unwrap();
assert!(import_theme(&src, custom_dir.path()).is_err());
}
#[test]
fn delete_theme_removes_and_guards() {
let custom = tempfile::tempdir().unwrap();
let path = custom.path().join("doomed.toml");
fs::write(&path, "[surface]\npage = \"#000\"\n").unwrap();
delete_theme(custom.path(), "doomed").unwrap();
assert!(!path.exists());
assert!(delete_theme(custom.path(), "../etc/passwd").is_err());
assert!(delete_theme(custom.path(), "ghost").is_err());
}
#[test]
fn export_theme_copies_file() {
let src_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let content = "[meta]\nname = \"E\"\n[surface]\npage = \"#ffffff\"\n";
fs::write(src_dir.path().join("e.toml"), content).unwrap();
let dirs = vec![(src_dir.path().to_path_buf(), false)];
let dest = dest_dir.path().join("out.toml");
export_theme(&dirs, "e", &dest).unwrap();
assert_eq!(fs::read_to_string(&dest).unwrap(), content);
assert!(export_theme(&dirs, "missing", &dest).is_err());
}
#[test]
fn load_theme_preview_returns_role_swatches() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
let dirs = vec![(dir.path().to_path_buf(), false)];
let p = load_theme_preview(&dirs, "nord").unwrap();
assert_eq!(p.background.as_deref(), Some("#2e3440")); assert_eq!(p.foreground.as_deref(), Some("#d8dee9")); assert_eq!(p.accent.as_deref(), Some("#81a1c1")); assert_eq!(p.border.as_deref(), Some("#4c566a")); }
#[test]
fn every_shipped_theme_loads() {
let dir = bundled_themes_dir().unwrap();
let dirs = vec![(dir.clone(), false)];
let themes = list_themes_from_dirs(&dirs);
assert!(
themes.len() >= 30,
"expected the full theme set, got {}",
themes.len()
);
for meta in &themes {
load_theme(&dirs, &meta.id)
.unwrap_or_else(|e| panic!("shipped theme `{}` failed to load: {e}", meta.id));
}
}
}