use std::sync::RwLock;
use crate::theme::contrast::{audit, Violation};
use crate::theme::registry::{hygiene_violations, themes, Theme};
use crate::theme::seeds::UPSTREAM_ALIASES;
use crate::theme::tokens::TokenSet;
#[derive(Clone, Debug)]
pub struct ThemeCandidate {
pub id: String,
pub label: String,
pub dark: bool,
pub tokens: TokenSet,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RegisterMode {
Strict,
Labeled,
}
#[derive(Debug)]
pub struct Registration {
pub theme: &'static Theme,
pub warnings: Vec<String>,
}
#[derive(Debug)]
pub enum RegisterError {
InvalidId(String),
ReservedId(String),
Rejected {
violations: Vec<Violation>,
hygiene: Vec<String>,
},
}
impl std::fmt::Display for RegisterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RegisterError::InvalidId(id) => {
write!(f, "invalid theme id '{id}': need non-empty [a-z0-9-_]")
}
RegisterError::ReservedId(id) => {
write!(f, "theme id '{id}' is reserved by a built-in theme")
}
RegisterError::Rejected {
violations,
hygiene,
} => {
write!(
f,
"theme rejected: {} contrast violation(s), {} hygiene finding(s)",
violations.len(),
hygiene.len()
)?;
for v in violations {
write!(f, "\n {v}")?;
}
for h in hygiene {
write!(f, "\n {h}")?;
}
Ok(())
}
}
}
}
static USER_THEMES: RwLock<Vec<&'static Theme>> = RwLock::new(Vec::new());
fn read_user() -> Vec<&'static Theme> {
USER_THEMES
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn register(
candidate: ThemeCandidate,
mode: RegisterMode,
) -> Result<Registration, RegisterError> {
let id = candidate.id.trim();
let id_ok = !id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
if !id_ok {
return Err(RegisterError::InvalidId(candidate.id));
}
let reserved = themes().iter().any(|t| t.id == id)
|| UPSTREAM_ALIASES.iter().any(|(alias, _)| *alias == id);
if reserved {
return Err(RegisterError::ReservedId(id.to_string()));
}
let label = if candidate.label.trim().is_empty() {
id.to_string()
} else {
candidate.label.trim().to_string()
};
let probe = Theme {
id: "",
label: "",
dark: candidate.dark,
tokens: candidate.tokens,
};
let violations = audit(id, &candidate.tokens);
let mut hygiene = hygiene_probe(id, &probe);
let measured_dark = candidate.tokens.bg.luminance() < 0.5;
if measured_dark != candidate.dark {
hygiene.push(format!(
"[{}] declared {} but ground measures {}",
id,
if candidate.dark { "dark" } else { "light" },
if measured_dark { "dark" } else { "light" }
));
}
let clean = violations.is_empty() && hygiene.is_empty();
if !clean && mode == RegisterMode::Strict {
return Err(RegisterError::Rejected {
violations,
hygiene,
});
}
let warnings: Vec<String> = violations
.iter()
.map(|v| format!("#FALLBACK: registered theme {v}"))
.chain(
hygiene
.iter()
.map(|h| format!("#FALLBACK: registered theme hygiene {h}")),
)
.collect();
if let Some(existing) = read_user().iter().rev().find(|t| t.id == id) {
if existing.label == label
&& existing.dark == candidate.dark
&& existing.tokens == candidate.tokens
{
return Ok(Registration {
theme: existing,
warnings,
});
}
}
let theme: &'static Theme = Box::leak(Box::new(Theme {
id: Box::leak(id.to_string().into_boxed_str()),
label: Box::leak(label.into_boxed_str()),
dark: candidate.dark,
tokens: candidate.tokens,
}));
USER_THEMES
.write()
.unwrap_or_else(|e| e.into_inner())
.push(theme);
Ok(Registration { theme, warnings })
}
fn hygiene_probe(id: &str, probe: &Theme) -> Vec<String> {
hygiene_violations(probe)
.into_iter()
.map(|line| line.replacen("[]", &format!("[{id}]"), 1))
.collect()
}
pub fn user_get(id: &str) -> Option<&'static Theme> {
let id = id.trim();
read_user().into_iter().rev().find(|t| t.id == id)
}
pub fn user_list() -> Vec<&'static Theme> {
let all = read_user();
let mut seen: Vec<&str> = Vec::new();
let mut out: Vec<&'static Theme> = Vec::new();
for t in all.iter().rev() {
if !seen.contains(&t.id) {
seen.push(t.id);
out.push(t);
}
}
out.reverse();
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::registry::{default_theme, get, resolve};
fn candidate(id: &str) -> ThemeCandidate {
let base = default_theme();
ThemeCandidate {
id: id.to_string(),
label: format!("Test {id}"),
dark: base.dark,
tokens: base.tokens,
}
}
#[test]
fn strict_accepts_a_clean_theme_and_lookup_finds_it() {
let reg = register(candidate("reg-clean"), RegisterMode::Strict).expect("clean");
assert!(reg.warnings.is_empty());
assert_eq!(reg.theme.id, "reg-clean");
assert_eq!(user_get("reg-clean").unwrap().id, "reg-clean");
assert_eq!(get("reg-clean").unwrap().tokens, reg.theme.tokens);
let (t, warn) = resolve("reg-clean");
assert_eq!(t.id, "reg-clean");
assert!(warn.is_none());
}
#[test]
fn strict_refuses_a_sub_contrast_theme_and_stores_nothing() {
let mut c = candidate("reg-bad-strict");
c.tokens.text = c.tokens.bg; match register(c, RegisterMode::Strict) {
Err(RegisterError::Rejected { violations, .. }) => {
assert!(violations.iter().any(|v| v.rule == "text/bg"));
assert!(violations.iter().all(|v| v.theme == "reg-bad-strict"));
}
other => panic!("expected Rejected, got {other:?}"),
}
assert!(
user_get("reg-bad-strict").is_none(),
"rejected must not store"
);
}
#[test]
fn labeled_registers_with_fallback_warnings() {
let mut c = candidate("reg-bad-labeled");
c.tokens.text_muted = c.tokens.bg;
let reg = register(c, RegisterMode::Labeled).expect("labeled stores");
assert!(!reg.warnings.is_empty());
assert!(
reg.warnings.iter().all(|w| w.starts_with("#FALLBACK")),
"warnings must be labeled: {:?}",
reg.warnings
);
assert!(user_get("reg-bad-labeled").is_some(), "labeled mode stores");
}
#[test]
fn identity_problems_refuse_in_both_modes() {
for mode in [RegisterMode::Strict, RegisterMode::Labeled] {
assert!(matches!(
register(candidate("nord"), mode),
Err(RegisterError::ReservedId(_))
));
assert!(matches!(
register(candidate("dark"), mode),
Err(RegisterError::ReservedId(_))
));
assert!(matches!(
register(candidate("Bad Id!"), mode),
Err(RegisterError::InvalidId(_))
));
assert!(matches!(
register(candidate(" "), mode),
Err(RegisterError::InvalidId(_))
));
}
}
#[test]
fn polarity_lie_is_caught() {
let mut c = candidate("reg-polarity");
c.dark = false; match register(c, RegisterMode::Strict) {
Err(RegisterError::Rejected { hygiene, .. }) => {
assert!(hygiene.iter().any(|h| h.contains("declared light")));
}
other => panic!("expected polarity rejection, got {other:?}"),
}
}
#[test]
fn re_register_replaces_lookup_and_dedups_identical() {
let first = register(candidate("reg-replace"), RegisterMode::Strict).expect("v1");
let again = register(candidate("reg-replace"), RegisterMode::Strict).expect("dedup");
assert!(
std::ptr::eq(first.theme, again.theme),
"identical candidate dedups"
);
let mut v2 = candidate("reg-replace");
v2.label = "Test reg-replace v2".to_string();
let second = register(v2, RegisterMode::Strict).expect("v2");
assert!(!std::ptr::eq(first.theme, second.theme));
assert_eq!(
user_get("reg-replace").unwrap().label,
"Test reg-replace v2"
);
assert_eq!(first.theme.id, "reg-replace", "old handle never dangles");
let entries: Vec<_> = user_list()
.into_iter()
.filter(|t| t.id == "reg-replace")
.collect();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].label, "Test reg-replace v2");
}
}