use crate::{
COLOR_SECTIONS, Emphasis, Rgb, STEP_FLOOR, SemanticTokens, ThemeColors, ThemeDirs, ThemeMeta,
Variant, find_theme_path, resolve, tonal, wcag_contrast,
};
use serde::Serialize;
use std::borrow::{Borrow, Cow};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
#[allow(unused_imports)]
use crate::ansi_intent;
const MAX_THEME_ID: usize = 64;
const fn well_formed(id: &str) -> bool {
let bytes = id.as_bytes();
if bytes.is_empty() || bytes.len() > MAX_THEME_ID {
return false;
}
let mut at = 0;
while at < bytes.len() {
let b = bytes[at];
if !(b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
return false;
}
at += 1;
}
true
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(transparent)]
pub struct ThemeId(Cow<'static, str>);
impl ThemeId {
pub fn new(id: impl Into<String>) -> Result<Self, LoadError> {
let id = id.into();
if well_formed(&id) {
Ok(Self(Cow::Owned(id)))
} else {
Err(LoadError::InvalidId(id))
}
}
pub const fn from_static(id: &'static str) -> Self {
assert!(well_formed(id), "not a theme id");
Self(Cow::Borrowed(id))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ThemeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for ThemeId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for ThemeId {
fn borrow(&self) -> &str {
&self.0
}
}
impl PartialEq<str> for ThemeId {
fn eq(&self, other: &str) -> bool {
self.as_str() == other
}
}
impl PartialEq<&str> for ThemeId {
fn eq(&self, other: &&str) -> bool {
self.as_str() == *other
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum LoadError {
InvalidId(String),
NotFound(ThemeId),
Io {
action: &'static str,
path: PathBuf,
source: std::io::Error,
},
Parse {
origin: String,
source: toml::de::Error,
},
NoColourSection,
BadColour {
key: String,
value: String,
},
AlreadyCustom(ThemeId),
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidId(id) => write!(f, "Invalid theme ID: {id:?}"),
Self::NotFound(id) => write!(f, "Theme '{id}' not found"),
Self::Io {
action,
path,
source,
} => write!(f, "Failed to {action} {}: {source}", path.display()),
Self::Parse { origin, source } => write!(f, "Failed to parse {origin}: {source}"),
Self::NoColourSection => write!(
f,
"Theme file must have at least one color section ({})",
COLOR_SECTIONS.join(", ")
),
Self::BadColour { key, value } => {
write!(f, "Theme colour {key} is not a hex colour: {value:?}")
}
Self::AlreadyCustom(id) => write!(
f,
"A custom theme '{id}' already exists; delete it before importing a replacement"
),
}
}
}
impl std::error::Error for LoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
_ => None,
}
}
}
fn io(action: &'static str, path: &Path) -> impl FnOnce(std::io::Error) -> LoadError {
let path = path.to_path_buf();
move |source| LoadError::Io {
action,
path,
source,
}
}
const MAX_THEME_BYTES: u64 = 16 * 1024 * 1024;
fn read_theme(path: &Path) -> Result<toml::Table, LoadError> {
let content = read_theme_bytes(path)?;
content.parse().map_err(|source| LoadError::Parse {
origin: path.display().to_string(),
source,
})
}
fn read_theme_bytes(path: &Path) -> Result<String, LoadError> {
let meta = std::fs::metadata(path).map_err(io("read", path))?;
if !meta.is_file() {
return Err(LoadError::Io {
action: "read",
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a regular file"),
});
}
if meta.len() > MAX_THEME_BYTES {
return Err(LoadError::Io {
action: "read",
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, "theme file is too large"),
});
}
std::fs::read_to_string(path).map_err(io("read", path))
}
fn unparseable_color(table: &toml::Table) -> Option<(String, String)> {
COLOR_SECTIONS.iter().find_map(|section| {
let sect = table.get(*section)?.as_table()?;
sect.iter().find_map(|(key, val)| {
let parses = val.as_str().and_then(Rgb::from_hex).is_some();
(!parses).then(|| (format!("{section}.{key}"), val.to_string()))
})
})
}
pub fn parse_meta(id: &ThemeId, 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.as_str())
.to_string();
let variant = meta
.and_then(|m| m.get("variant"))
.and_then(|v| v.as_str())
.map_or(Variant::Dark, Variant::from);
ThemeMeta {
id: id.clone(),
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(crate) struct Scanned {
pub(crate) meta: ThemeMeta,
pub(crate) table: toml::Table,
}
impl Scanned {
pub(crate) fn colors(&self) -> ThemeColors {
ThemeColors {
meta: self.meta.clone(),
colors: extract_colors(&self.table),
}
}
}
pub(crate) fn scan(dirs: &ThemeDirs) -> (BTreeMap<ThemeId, Scanned>, Vec<(PathBuf, LoadError)>) {
let mut seen: BTreeMap<ThemeId, Scanned> = BTreeMap::new();
let mut broken_by_id: BTreeMap<ThemeId, (PathBuf, LoadError)> = BTreeMap::new();
let mut broken: Vec<(PathBuf, LoadError)> = Vec::new();
for (dir, is_custom) in dirs.search_path() {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
let mut paths: Vec<PathBuf> = entries
.filter_map(|entry| Some(entry.ok()?.path()))
.filter(|path| path.extension().and_then(|e| e.to_str()) == Some("toml"))
.collect();
paths.sort();
for path in paths {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let id = match ThemeId::new(stem) {
Ok(id) => id,
Err(error) => {
broken.push((path, error));
continue;
}
};
match read_theme(&path) {
Ok(table) => {
broken_by_id.remove(&id);
let meta = parse_meta(&id, &table, is_custom);
seen.insert(id, Scanned { meta, table });
}
Err(error) => {
seen.remove(&id);
broken_by_id.insert(id, (path, error));
}
}
}
}
broken.extend(broken_by_id.into_values());
broken.sort_by(|a, b| a.0.cmp(&b.0));
(seen, broken)
}
#[derive(Debug)]
pub struct Listed {
pub themes: Vec<ThemeMeta>,
pub broken: Vec<(PathBuf, LoadError)>,
}
pub fn list_themes_from_dirs(dirs: &ThemeDirs) -> Listed {
let (seen, broken) = scan(dirs);
let mut themes: Vec<ThemeMeta> = seen.into_values().map(|scanned| scanned.meta).collect();
themes.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));
Listed { themes, broken }
}
pub fn parse_theme_str(
id: &ThemeId,
content: &str,
is_custom: bool,
) -> Result<ThemeColors, LoadError> {
let table: toml::Table = content.parse().map_err(|source| LoadError::Parse {
origin: format!("theme '{id}'"),
source,
})?;
let meta = parse_meta(id, &table, is_custom);
let colors = extract_colors(&table);
Ok(ThemeColors { meta, colors })
}
fn read_by_id(dirs: &ThemeDirs, id: &ThemeId) -> Result<(toml::Table, bool), LoadError> {
let (path, is_custom) =
find_theme_path(dirs, id).ok_or_else(|| LoadError::NotFound(id.clone()))?;
Ok((read_theme(&path)?, is_custom))
}
pub fn load_theme(dirs: &ThemeDirs, id: &ThemeId) -> Result<ThemeColors, LoadError> {
let (table, is_custom) = read_by_id(dirs, id)?;
Ok(ThemeColors {
meta: parse_meta(id, &table, is_custom),
colors: extract_colors(&table),
})
}
pub fn load_semantic(dirs: &ThemeDirs, id: &ThemeId) -> Result<SemanticTokens, LoadError> {
Ok(resolve(&load_theme(dirs, id)?))
}
pub fn import_theme(source_path: &Path, custom_dir: &Path) -> Result<ThemeMeta, LoadError> {
let content = read_theme_bytes(source_path)?;
let table: toml::Table = content.parse().map_err(|source| LoadError::Parse {
origin: source_path.display().to_string(),
source,
})?;
if extract_colors(&table).is_empty() {
return Err(LoadError::NoColourSection);
}
if let Some((key, value)) = unparseable_color(&table) {
return Err(LoadError::BadColour { key, value });
}
let stem = source_path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let id = ThemeId::new(stem)?;
std::fs::create_dir_all(custom_dir).map_err(io("create", custom_dir))?;
let dest = custom_dir.join(format!("{id}.toml"));
if dest.exists() {
return Err(LoadError::AlreadyCustom(id));
}
write_new_atomic(&dest, &content)?;
Ok(parse_meta(&id, &table, true))
}
pub fn delete_theme(custom_dir: &Path, id: &ThemeId) -> Result<(), LoadError> {
let path = custom_dir.join(format!("{id}.toml"));
if !path.is_file() {
return Err(LoadError::NotFound(id.clone()));
}
std::fs::remove_file(&path).map_err(io("delete", &path))
}
#[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: &ThemeDirs, id: &ThemeId) -> Result<ThemePreview, LoadError> {
let (table, is_custom) = read_by_id(dirs, id)?;
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: &ThemeDirs, id: &ThemeId, dest_path: &Path) -> Result<(), LoadError> {
let (source, _) = find_theme_path(dirs, id).ok_or_else(|| LoadError::NotFound(id.clone()))?;
if same_file(&source, dest_path) {
return Err(LoadError::Io {
action: "copy",
path: dest_path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"source and destination are the same file",
),
});
}
std::fs::copy(&source, dest_path).map_err(io("copy", dest_path))?;
Ok(())
}
fn same_file(a: &Path, b: &Path) -> bool {
std::fs::canonicalize(a)
.ok()
.zip(std::fs::canonicalize(b).ok())
.is_some_and(|(a, b)| a == b)
}
fn write_new_atomic(dest: &Path, content: &str) -> Result<(), LoadError> {
use std::io::Write;
let tmp = dest.with_extension(format!("tmp-{}", std::process::id()));
let result = (|| -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;
std::fs::rename(&tmp, dest)
})();
if let Err(source) = result {
let _ = std::fs::remove_file(&tmp);
return Err(LoadError::Io {
action: "write",
path: dest.to_path_buf(),
source,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixture::nord_toml;
#[allow(unused_imports)]
use crate::{ThemeDirs, ThemeId, Token};
use crate::{bundled_themes_dir, embedded_themes};
use std::fs;
fn bundled(dir: &Path) -> ThemeDirs {
ThemeDirs::new().bundled(Some(dir.to_path_buf()))
}
fn custom(dir: &Path) -> ThemeDirs {
ThemeDirs::new().custom(Some(dir.to_path_buf()))
}
#[test]
fn a_theme_id_is_ascii_letters_digits_hyphens_and_underscores() {
for good in ["darkmode", "Theme123", "dark-mode", "my_theme_v2"] {
assert!(ThemeId::new(good).is_ok(), "{good}");
}
assert!(ThemeId::new("a".repeat(MAX_THEME_ID)).is_ok());
}
#[test]
fn a_theme_id_cannot_name_a_path_or_be_empty_long_or_unicode() {
for bad in [
"../etc/passwd",
"foo/bar",
"theme.toml",
"",
"caf\u{e9}",
"\u{65e5}\u{672c}",
"my theme",
] {
assert!(
matches!(ThemeId::new(bad), Err(LoadError::InvalidId(_))),
"{bad:?}"
);
}
assert!(ThemeId::new("a".repeat(MAX_THEME_ID + 1)).is_err());
}
#[test]
fn a_static_id_is_checked_at_compile_time() {
const HOUSE: ThemeId = ThemeId::from_static("akari-night");
assert_eq!(HOUSE, "akari-night");
assert_eq!(HOUSE, ThemeId::new("akari-night").unwrap());
}
#[test]
#[should_panic(expected = "not a theme id")]
fn a_static_id_that_is_not_one_panics() {
let _ = ThemeId::from_static("../evil");
}
#[test]
fn listing_reports_what_cannot_load_and_orders_ties_by_id() {
let dir = tempfile::tempdir().unwrap();
let body = "[meta]\nname = \"Same\"\n[surface]\npage = \"#000000\"\n";
for stem in ["zeta", "alpha", "my theme", "caf\u{e9}"] {
fs::write(dir.path().join(format!("{stem}.toml")), body).unwrap();
}
fs::write(dir.path().join("torn.toml"), "this is not = = toml").unwrap();
let dirs = custom(dir.path());
let listed = list_themes_from_dirs(&dirs);
let ids: Vec<&str> = listed.themes.iter().map(|m| m.id.as_str()).collect();
assert_eq!(ids, ["alpha", "zeta"]);
for meta in &listed.themes {
load_theme(&dirs, &meta.id).unwrap();
}
let mut broken: Vec<(String, bool)> = listed
.broken
.iter()
.map(|(path, error)| {
(
path.file_name().unwrap().to_string_lossy().into_owned(),
matches!(error, LoadError::InvalidId(_)),
)
})
.collect();
broken.sort();
assert_eq!(
broken,
[
("caf\u{e9}.toml".to_string(), true),
("my theme.toml".to_string(), true),
("torn.toml".to_string(), false),
]
);
}
#[test]
fn a_broken_override_is_broken_rather_than_served_from_below() {
let low = tempfile::tempdir().unwrap();
let high = tempfile::tempdir().unwrap();
fs::write(low.path().join("mine.toml"), "[meta]\nname = \"Low\"\n").unwrap();
fs::write(high.path().join("mine.toml"), "not = = toml").unwrap();
let dirs = ThemeDirs::new()
.bundled(Some(low.path().to_path_buf()))
.custom(Some(high.path().to_path_buf()));
let listed = list_themes_from_dirs(&dirs);
assert!(
listed.themes.is_empty(),
"load_theme would read the broken one"
);
assert_eq!(listed.broken.len(), 1);
assert!(load_theme(&dirs, &ThemeId::from_static("mine")).is_err());
}
#[test]
fn a_load_error_keeps_its_cause() {
use std::error::Error as _;
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("torn.toml"), "not = = toml").unwrap();
let err = load_theme(&bundled(dir.path()), &ThemeId::from_static("torn")).unwrap_err();
assert!(matches!(err, LoadError::Parse { .. }), "{err}");
assert!(err.source().is_some());
assert!(err.to_string().contains("torn.toml"), "{err}");
}
#[test]
fn import_refuses_an_unparseable_colour_and_names_it() {
let src_dir = tempfile::tempdir().unwrap();
let custom_dir = tempfile::tempdir().unwrap();
let src = src_dir.path().join("typo.toml");
fs::write(&src, "[surface]\npage = \"#1a1b2g\"\n").unwrap();
let err = import_theme(&src, custom_dir.path()).unwrap_err();
assert!(
matches!(&err, LoadError::BadColour { key, .. } if key == "surface.page"),
"{err}"
);
assert!(!custom_dir.path().join("typo.toml").exists());
}
#[test]
fn import_refuses_to_replace_an_existing_custom_theme() {
let src_dir = tempfile::tempdir().unwrap();
let custom_dir = tempfile::tempdir().unwrap();
let mine = custom_dir.path().join("shared.toml");
fs::write(&mine, "[surface]\npage = \"#111111\"\n").unwrap();
let src = src_dir.path().join("shared.toml");
fs::write(&src, "[surface]\npage = \"#222222\"\n").unwrap();
let err = import_theme(&src, custom_dir.path()).unwrap_err();
assert!(matches!(err, LoadError::AlreadyCustom(_)), "{err}");
assert!(fs::read_to_string(&mine).unwrap().contains("#111111"));
}
#[test]
fn every_embedded_theme_would_import() {
for (id, content) in embedded_themes() {
let table: toml::Table = content.parse().unwrap();
assert_eq!(unparseable_color(&table), None, "{id}");
}
}
#[test]
fn parse_meta_with_name_and_variant() {
let table: toml::Table = "[meta]\nname = \"Nord\"\nvariant = \"light\"\n"
.parse()
.unwrap();
let meta = parse_meta(&ThemeId::from_static("nord"), &table, false);
assert_eq!(meta.id, "nord");
assert_eq!(meta.name, "Nord");
assert_eq!(meta.variant, 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(&ThemeId::from_static("fallback"), &table, true);
assert_eq!(meta.name, "fallback");
assert_eq!(meta.variant, 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(&ThemeId::new(id).unwrap(), toml, false).unwrap();
let t = resolve(&theme);
let page = t.color(Token::SurfacePage).unwrap();
let steps = [Token::Content, Token::ContentSecondary, Token::ContentMuted]
.map(|k| wcag_contrast(t.color(k).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(&ThemeId::new(id).unwrap(), toml, false).unwrap();
let t = resolve(&theme);
let ink = t.color(Token::Content).unwrap();
let secondary = t.color(Token::ContentSecondary).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(&ThemeId::from_static("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 t = load_semantic(&bundled(dir.path()), &ThemeId::from_static("nord")).unwrap();
assert_eq!(t.meta.name, "Nord");
assert_eq!(t.hex(Token::Action).as_deref(), Some("#81a1c1"));
}
#[test]
fn load_theme_reports_an_id_it_cannot_find() {
let err = load_theme(&ThemeDirs::new(), &ThemeId::from_static("ghost")).unwrap_err();
assert!(matches!(err, LoadError::NotFound(_)), "{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 themes = list_themes_from_dirs(&bundled(dir.path())).themes;
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 import_refuses_a_non_string_colour_value() {
let src_dir = tempfile::tempdir().unwrap();
let custom_dir = tempfile::tempdir().unwrap();
let src = src_dir.path().join("mixed.toml");
fs::write(
&src,
"[surface]\npage = \"#1a1b26\"\n[content]\nprimary = 0xffffff\n",
)
.unwrap();
let err = import_theme(&src, custom_dir.path()).unwrap_err();
assert!(
matches!(&err, LoadError::BadColour { key, .. } if key == "content.primary"),
"{err}"
);
assert!(!custom_dir.path().join("mixed.toml").exists());
}
#[test]
fn a_symlink_to_a_device_is_rejected_not_read() {
let dir = tempfile::tempdir().unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink("/dev/zero", dir.path().join("zero.toml")).unwrap();
let listed = list_themes_from_dirs(&bundled(dir.path()));
assert!(listed.themes.is_empty());
assert_eq!(listed.broken.len(), 1);
}
}
#[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(), &ThemeId::from_static("doomed")).unwrap();
assert!(!path.exists());
assert!(delete_theme(custom.path(), &ThemeId::from_static("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 = bundled(src_dir.path());
let dest = dest_dir.path().join("out.toml");
export_theme(&dirs, &ThemeId::from_static("e"), &dest).unwrap();
assert_eq!(fs::read_to_string(&dest).unwrap(), content);
assert!(export_theme(&dirs, &ThemeId::from_static("missing"), &dest).is_err());
}
#[test]
fn export_theme_refuses_to_copy_onto_itself() {
let src_dir = tempfile::tempdir().unwrap();
let content = "[surface]\npage = \"#ffffff\"\n";
let path = src_dir.path().join("e.toml");
fs::write(&path, content).unwrap();
let dirs = bundled(src_dir.path());
let err = export_theme(&dirs, &ThemeId::from_static("e"), &path).unwrap_err();
assert!(matches!(err, LoadError::Io { .. }), "{err}");
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn load_theme_preview_returns_role_swatches() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("nord.toml"), nord_toml()).unwrap();
let p = load_theme_preview(&bundled(dir.path()), &ThemeId::from_static("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 dirs = ThemeDirs::new().bundled(bundled_themes_dir());
let listed = list_themes_from_dirs(&dirs);
assert!(listed.broken.is_empty(), "{:?}", listed.broken);
let themes = listed.themes;
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));
}
}
}