mod error;
pub use error::{Result, TokenError};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenFile {
pub themes: HashMap<String, HashMap<String, String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CssFallbackChain {
pub tokens: Vec<String>,
pub has_literal_fallback: bool,
}
#[derive(Debug, Clone)]
pub struct ResolvedTokens {
pub css: HashMap<String, String>,
}
pub fn load_token_file(path: &Path) -> Result<TokenFile> {
let content = std::fs::read_to_string(path).map_err(|e| TokenError::io(path, e))?;
parse_token_content(&content, path)
}
pub fn parse_token_content(content: &str, source_path: &Path) -> Result<TokenFile> {
let value: Value =
serde_json::from_str(content).map_err(|e| TokenError::json(source_path, e))?;
let obj = value
.as_object()
.ok_or_else(|| TokenError::Schema("Token file must be a JSON object".into()))?;
if let Some(themes_val) = obj.get("themes") {
let themes_obj = themes_val
.as_object()
.ok_or_else(|| TokenError::Schema("\"themes\" must be an object".into()))?;
if themes_obj.is_empty() {
return Err(TokenError::Schema(
"\"themes\" must contain at least one theme".into(),
));
}
let mut themes = HashMap::with_capacity(themes_obj.len());
for (theme_name, theme_val) in themes_obj {
let tokens = parse_flat_token_map(theme_val, theme_name)?;
themes.insert(theme_name.clone(), tokens);
}
return Ok(TokenFile { themes });
}
let tokens = parse_flat_token_map(&value, "default")?;
if tokens.is_empty() {
return Err(TokenError::Schema(
"Token file contains no token definitions".into(),
));
}
let mut themes = HashMap::with_capacity(1);
themes.insert("default".into(), tokens);
Ok(TokenFile { themes })
}
fn parse_flat_token_map(value: &Value, theme_name: &str) -> Result<HashMap<String, String>> {
let obj = value
.as_object()
.ok_or_else(|| TokenError::Schema(format!("Theme '{theme_name}' must be a JSON object")))?;
let mut tokens = HashMap::with_capacity(obj.len());
for (key, val) in obj {
if key.starts_with('$') {
continue;
}
if key == "themes" {
continue;
}
match val.as_str() {
Some(s) => {
tokens.insert(key.clone(), s.to_string());
}
None => {
return Err(TokenError::Schema(format!(
"Token '{key}' in theme '{theme_name}' must be a string value, got {}",
value_type_name(val)
)));
}
}
}
Ok(tokens)
}
fn value_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub fn resolve_theme_path(theme: &str, search_root: &Path) -> Result<PathBuf> {
let as_path = PathBuf::from(theme);
if as_path.exists() {
return as_path
.canonicalize()
.map_err(|e| TokenError::Schema(format!("Failed to canonicalize {theme}: {e}")));
}
let (pkg, subpath) = if theme.starts_with('@') {
let parts: Vec<&str> = theme.splitn(3, '/').collect();
if parts.len() >= 3 {
let pkg = format!("{}/{}", parts[0], parts[1]);
(pkg, parts[2].to_string())
} else if parts.len() == 2 {
(theme.to_string(), "tokens.json".to_string())
} else {
return Err(TokenError::Schema(format!(
"Invalid scoped package name: {theme}"
)));
}
} else if let Some((pkg, sub)) = theme.split_once('/') {
(pkg.to_string(), sub.to_string())
} else {
(theme.to_string(), "tokens.json".to_string())
};
let mut dir = search_root;
loop {
let candidate = dir.join("node_modules").join(&pkg).join(&subpath);
if candidate.exists() {
return candidate.canonicalize().map_err(|e| {
TokenError::Schema(format!(
"Failed to canonicalize {}: {e}",
candidate.display()
))
});
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
Err(TokenError::Schema(format!(
"Theme '{theme}' not found. Looked for:\n \
1. File: {theme}\n \
2. Package: node_modules/{pkg}/{subpath} (walked up from {})\n\n \
Make sure the package is installed (pnpm install) or the path is correct.",
search_root.display()
)))
}
pub fn resolve_tokens(
protocol_tokens: &[String],
token_file: &TokenFile,
) -> Result<ResolvedTokens> {
let mut css = HashMap::with_capacity(token_file.themes.len());
for (theme_name, theme_tokens) in &token_file.themes {
let css_string = resolve_theme(protocol_tokens, theme_tokens);
css.insert(theme_name.clone(), css_string);
}
Ok(ResolvedTokens { css })
}
pub fn validate_required_tokens(required_tokens: &[String], token_file: &TokenFile) -> Result<()> {
for (theme_name, theme_tokens) in &token_file.themes {
validate_theme_required_tokens(required_tokens, theme_tokens, theme_name)?;
}
Ok(())
}
pub fn validate_chain_tokens(chains: &[CssFallbackChain], token_file: &TokenFile) -> Result<()> {
validate_required_tokens(&required_chain_tokens(chains), token_file)
}
#[must_use]
pub fn unthemed_literal_fallback_tokens(
chains: &[CssFallbackChain],
token_file: &TokenFile,
) -> Vec<String> {
let required: HashSet<&str> = chains
.iter()
.filter(|chain| !chain.has_literal_fallback)
.flat_map(|chain| chain.tokens.iter().map(String::as_str))
.collect();
let mut unthemed: Vec<String> = chains
.iter()
.flat_map(|chain| chain.tokens.iter())
.filter(|token| !required.contains(token.as_str()))
.filter(|token| {
token_file
.themes
.values()
.all(|theme| !theme.contains_key(token.as_str()))
})
.cloned()
.collect();
unthemed.sort_unstable();
unthemed.dedup();
unthemed
}
fn required_chain_tokens(chains: &[CssFallbackChain]) -> Vec<String> {
let mut required: Vec<String> = chains
.iter()
.filter(|chain| !chain.has_literal_fallback)
.flat_map(|chain| chain.tokens.iter().cloned())
.collect();
required.sort_unstable();
required.dedup();
required
}
fn resolve_theme(required_tokens: &[String], theme_tokens: &HashMap<String, String>) -> String {
let present_tokens: Vec<String> = required_tokens
.iter()
.filter(|token| theme_tokens.contains_key(*token))
.cloned()
.collect();
let closure = theme_token_closure(&present_tokens, theme_tokens);
generate_css_declarations(&closure, theme_tokens)
}
fn validate_theme_required_tokens(
required_tokens: &[String],
theme_tokens: &HashMap<String, String>,
theme_name: &str,
) -> Result<()> {
for name in required_tokens {
if !theme_tokens.contains_key(name) {
return Err(TokenError::MissingToken {
theme: theme_name.into(),
token: name.clone(),
});
}
}
Ok(())
}
fn theme_token_closure(
required_tokens: &[String],
theme_tokens: &HashMap<String, String>,
) -> Vec<String> {
let required: HashSet<&str> = required_tokens.iter().map(String::as_str).collect();
compute_token_closure(&required, theme_tokens)
}
fn compute_token_closure(
required: &HashSet<&str>,
theme_tokens: &HashMap<String, String>,
) -> Vec<String> {
let mut closure: HashSet<String> = HashSet::new();
let mut queue: Vec<String> = required
.iter()
.filter(|name| theme_tokens.contains_key(**name))
.map(|s| (*s).into())
.collect();
while let Some(name) = queue.pop() {
if !closure.insert(name.clone()) {
continue;
}
if let Some(value) = theme_tokens.get(&name) {
let deps = extract_var_references(value);
for dep in deps {
if theme_tokens.contains_key(&dep) && !closure.contains(&dep) {
queue.push(dep);
}
}
}
}
let mut sorted: Vec<String> = closure.into_iter().collect();
sorted.sort();
sorted
}
#[must_use]
pub fn extract_var_references(value: &str) -> Vec<String> {
let mut refs = Vec::new();
let bytes = value.as_bytes();
let len = bytes.len();
let mut i = 0;
while i + 5 < len {
if bytes[i] == b'v' && bytes[i + 1] == b'a' && bytes[i + 2] == b'r' && bytes[i + 3] == b'('
{
i += 4;
while i < len && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
i += 2;
let start = i;
while i < len
&& (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
{
i += 1;
}
if i > start {
let name = &value[start..i];
refs.push(name.into());
}
}
} else {
i += 1;
}
}
refs
}
fn generate_css_declarations(closure: &[String], theme_tokens: &HashMap<String, String>) -> String {
let estimated_capacity: usize = closure
.iter()
.map(|name| {
4 + name.len() + theme_tokens.get(name).map_or(0, |v| v.len()) + 2
})
.sum();
let mut css = String::with_capacity(estimated_capacity);
for (idx, name) in closure.iter().enumerate() {
if let Some(value) = theme_tokens.get(name) {
if idx > 0 {
css.push('\n');
}
css.push_str("--");
css.push_str(name);
css.push_str(": ");
css.push_str(value);
css.push(';');
}
}
css
}
pub fn inject_into_state(state: &mut Value, resolved: &ResolvedTokens) {
let Some(state_map) = state.as_object_mut() else {
return;
};
let tokens_obj = state_map
.entry("tokens")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(map) = tokens_obj.as_object_mut() {
for (theme_name, css_string) in &resolved.css {
map.insert(theme_name.clone(), Value::String(css_string.clone()));
}
}
}
pub fn inject_token_css(state: &mut Value, token_css: &HashMap<String, String>) {
let Some(state_map) = state.as_object_mut() else {
return;
};
let tokens_obj = state_map
.entry("tokens")
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(map) = tokens_obj.as_object_mut() {
for (theme, css) in token_css {
map.insert(theme.clone(), Value::String(css.clone()));
}
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn extract_var_refs_simple() {
let refs = extract_var_references("var(--color-primary)");
assert_eq!(refs, vec!["color-primary"]);
}
#[test]
fn extract_var_refs_multiple() {
let refs =
extract_var_references("color-mix(in oklch, var(--accent) 15%, var(--background))");
assert_eq!(refs, vec!["accent", "background"]);
}
#[test]
fn extract_var_refs_with_fallback() {
let refs = extract_var_references("var(--color-brand, #0078d4)");
assert_eq!(refs, vec!["color-brand"]);
}
#[test]
fn extract_var_refs_nested() {
let refs = extract_var_references("var(--a, var(--b))");
assert_eq!(refs, vec!["a", "b"]);
}
#[test]
fn extract_var_refs_with_whitespace() {
let refs = extract_var_references("var( --spaced-name )");
assert_eq!(refs, vec!["spaced-name"]);
}
#[test]
fn extract_var_refs_no_refs() {
let refs = extract_var_references("#0078d4");
assert!(refs.is_empty());
}
#[test]
fn extract_var_refs_underscore_name() {
let refs = extract_var_references("var(--my_token)");
assert_eq!(refs, vec!["my_token"]);
}
#[test]
fn extract_var_refs_calc_expression() {
let refs = extract_var_references("calc(var(--spacing-m) * 2)");
assert_eq!(refs, vec!["spacing-m"]);
}
#[test]
fn extract_var_refs_empty_string() {
let refs = extract_var_references("");
assert!(refs.is_empty());
}
#[test]
fn extract_var_refs_short_string() {
let refs = extract_var_references("abc");
assert!(refs.is_empty());
}
fn write_json_file(value: &Value) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
serde_json::to_writer(&mut file, value).unwrap();
file
}
#[test]
fn load_multi_theme_file() {
let json = serde_json::json!({
"themes": {
"light": { "surface-page": "#ffffff", "text-primary": "#111827" },
"dark": { "surface-page": "#171717", "text-primary": "#fafafa" }
}
});
let file = write_json_file(&json);
let tokens = load_token_file(file.path()).unwrap();
assert_eq!(tokens.themes.len(), 2);
assert_eq!(tokens.themes["light"]["surface-page"], "#ffffff");
assert_eq!(tokens.themes["dark"]["text-primary"], "#fafafa");
}
#[test]
fn load_flat_single_theme_file() {
let json = serde_json::json!({
"surface-page": "#ffffff",
"text-primary": "#111827"
});
let file = write_json_file(&json);
let tokens = load_token_file(file.path()).unwrap();
assert_eq!(tokens.themes.len(), 1);
assert!(tokens.themes.contains_key("default"));
assert_eq!(tokens.themes["default"]["surface-page"], "#ffffff");
}
#[test]
fn load_file_with_schema_key() {
let json = serde_json::json!({
"$schema": "https://webui.dev/schemas/tokens.json",
"themes": {
"light": { "color-brand": "#0078d4" }
}
});
let file = write_json_file(&json);
let tokens = load_token_file(file.path()).unwrap();
assert_eq!(tokens.themes["light"]["color-brand"], "#0078d4");
}
#[test]
fn load_empty_themes_returns_error() {
let json = serde_json::json!({ "themes": {} });
let file = write_json_file(&json);
let result = load_token_file(file.path());
assert!(matches!(result, Err(TokenError::Schema(_))));
}
#[test]
fn load_non_string_value_returns_error() {
let json = serde_json::json!({ "themes": { "light": { "bad": 42 } } });
let file = write_json_file(&json);
let result = load_token_file(file.path());
assert!(
matches!(result, Err(TokenError::Schema(ref msg)) if msg.contains("must be a string")),
"expected schema error, got: {result:?}"
);
}
#[test]
fn load_missing_file_returns_io_error() {
let result = load_token_file(Path::new("/nonexistent/tokens.json"));
assert!(matches!(result, Err(TokenError::Io { .. })));
}
#[test]
fn load_invalid_json_returns_error() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"{ not json }").unwrap();
let result = load_token_file(file.path());
assert!(matches!(result, Err(TokenError::InvalidJson { .. })));
}
#[test]
fn resolve_filters_to_protocol_tokens() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("surface-page".into(), "#ffffff".into()),
("text-primary".into(), "#111827".into()),
("unused-token".into(), "#999999".into()),
]),
)]),
};
let protocol_tokens = vec!["surface-page".into(), "text-primary".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
let css = &resolved.css["light"];
assert!(css.contains("--surface-page: #ffffff;"));
assert!(css.contains("--text-primary: #111827;"));
assert!(!css.contains("unused-token"));
}
#[test]
fn resolve_expands_transitive_dependencies() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("surface-page".into(), "var(--neutral-100)".into()),
("neutral-100".into(), "#f9fafb".into()),
]),
)]),
};
let protocol_tokens = vec!["surface-page".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
let css = &resolved.css["light"];
assert!(
css.contains("--neutral-100: #f9fafb;"),
"transitive dep should be included: {css}"
);
assert!(css.contains("--surface-page: var(--neutral-100);"));
}
#[test]
fn resolve_deep_transitive_chain() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("a".into(), "var(--b)".into()),
("b".into(), "var(--c)".into()),
("c".into(), "#000".into()),
]),
)]),
};
let protocol_tokens = vec!["a".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
let css = &resolved.css["light"];
assert!(css.contains("--a: var(--b);"), "a should be in CSS: {css}");
assert!(css.contains("--b: var(--c);"), "b should be in CSS: {css}");
assert!(css.contains("--c: #000;"), "c should be in CSS: {css}");
}
#[test]
fn resolve_trusts_direct_cycle() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("a".into(), "var(--b)".into()),
("b".into(), "var(--a)".into()),
]),
)]),
};
let protocol_tokens = vec!["a".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
let css = &resolved.css["light"];
assert!(
css.contains("--a: var(--b);"),
"a should be included: {css}"
);
assert!(
css.contains("--b: var(--a);"),
"b should be included: {css}"
);
}
#[test]
fn resolve_trusts_self_referencing_cycle() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([("a".into(), "var(--a)".into())]),
)]),
};
let protocol_tokens = vec!["a".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
assert_eq!(resolved.css["light"], "--a: var(--a);");
}
#[test]
fn resolve_skips_missing_protocol_token() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([("surface-page".into(), "#fff".into())]),
)]),
};
let protocol_tokens = vec!["surface-page".into(), "nonexistent".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
assert_eq!(resolved.css["light"], "--surface-page: #fff;");
}
#[test]
fn resolve_trusts_missing_dependency() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([("surface-page".into(), "var(--not-defined)".into())]),
)]),
};
let protocol_tokens = vec!["surface-page".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
assert_eq!(resolved.css["light"], "--surface-page: var(--not-defined);");
}
#[test]
fn validate_required_tokens_accepts_complete_theme() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("surface-page".into(), "var(--neutral-100)".into()),
("neutral-100".into(), "#fff".into()),
]),
)]),
};
let protocol_tokens = vec!["surface-page".into()];
validate_required_tokens(&protocol_tokens, &token_file).unwrap();
}
#[test]
fn validate_required_tokens_errors_on_missing_token() {
let token_file = TokenFile {
themes: HashMap::from([("light".into(), HashMap::new())]),
};
let protocol_tokens = vec!["surface-page".into()];
let result = validate_required_tokens(&protocol_tokens, &token_file);
assert!(
matches!(result, Err(TokenError::MissingToken { ref token, .. }) if token == "surface-page"),
"expected missing token error, got: {result:?}"
);
}
fn chain(tokens: &[&str], has_literal_fallback: bool) -> CssFallbackChain {
CssFallbackChain {
tokens: tokens.iter().map(|t| (*t).to_string()).collect(),
has_literal_fallback,
}
}
#[test]
fn validate_chain_tokens_requires_all_non_literal_candidates() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([("token-a".into(), "red".into())]),
)]),
};
let chains = vec![chain(&["token-a", "token-b"], false)];
let result = validate_chain_tokens(&chains, &token_file);
assert!(
matches!(result, Err(TokenError::MissingToken { ref token, .. }) if token == "token-b"),
"expected token-b missing, got: {result:?}"
);
}
#[test]
fn validate_chain_tokens_exempts_literal_fallback_chain() {
let token_file = TokenFile {
themes: HashMap::from([("light".into(), HashMap::new())]),
};
let chains = vec![chain(&["brand"], true)];
validate_chain_tokens(&chains, &token_file).unwrap();
}
#[test]
fn validate_chain_tokens_requires_token_with_any_bare_usage() {
let token_file = TokenFile {
themes: HashMap::from([("light".into(), HashMap::new())]),
};
let chains = vec![chain(&["brand"], true), chain(&["brand"], false)];
let result = validate_chain_tokens(&chains, &token_file);
assert!(
matches!(result, Err(TokenError::MissingToken { ref token, .. }) if token == "brand"),
"expected brand required, got: {result:?}"
);
}
#[test]
fn unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("present".into(), "2px".into()),
("required".into(), "8px".into()),
]),
)]),
};
let chains = vec![
chain(&["colr-brand"], true), chain(&["present"], true), chain(&["required"], false), ];
assert_eq!(
unthemed_literal_fallback_tokens(&chains, &token_file),
vec!["colr-brand".to_string()]
);
}
#[test]
fn unthemed_literal_fallback_tokens_does_not_flag_token_with_bare_usage() {
let token_file = TokenFile {
themes: HashMap::from([("light".into(), HashMap::new())]),
};
let chains = vec![chain(&["brand"], true), chain(&["brand"], false)];
assert!(unthemed_literal_fallback_tokens(&chains, &token_file).is_empty());
}
#[test]
fn resolve_multiple_themes() {
let token_file = TokenFile {
themes: HashMap::from([
(
"light".into(),
HashMap::from([("surface-page".into(), "#ffffff".into())]),
),
(
"dark".into(),
HashMap::from([("surface-page".into(), "#171717".into())]),
),
]),
};
let protocol_tokens = vec!["surface-page".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
assert_eq!(resolved.css["light"], "--surface-page: #ffffff;");
assert_eq!(resolved.css["dark"], "--surface-page: #171717;");
}
#[test]
fn resolve_empty_protocol_tokens_produces_empty_css() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([("surface-page".into(), "#fff".into())]),
)]),
};
let resolved = resolve_tokens(&[], &token_file).unwrap();
assert_eq!(resolved.css["light"], "");
}
#[test]
fn resolve_complex_css_values() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("shadow-card".into(), "0 8px 32px rgba(0,0,0,0.3)".into()),
(
"accent-subtle".into(),
"color-mix(in oklch, var(--accent) 15%, transparent)".into(),
),
("accent".into(), "#0078d4".into()),
]),
)]),
};
let protocol_tokens = vec!["shadow-card".into(), "accent-subtle".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
let css = &resolved.css["light"];
assert!(
css.contains("--accent: #0078d4;"),
"transitive dep 'accent' should be included: {css}"
);
assert!(css.contains("--shadow-card: 0 8px 32px rgba(0,0,0,0.3);"));
}
#[test]
fn resolve_css_output_is_sorted() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("z-token".into(), "z".into()),
("a-token".into(), "a".into()),
("m-token".into(), "m".into()),
]),
)]),
};
let protocol_tokens = vec!["z-token".into(), "a-token".into(), "m-token".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
let css = &resolved.css["light"];
let lines: Vec<&str> = css.lines().collect();
assert_eq!(lines[0], "--a-token: a;");
assert_eq!(lines[1], "--m-token: m;");
assert_eq!(lines[2], "--z-token: z;");
}
#[test]
fn inject_into_empty_state() {
let mut state = serde_json::json!({});
let resolved = ResolvedTokens {
css: HashMap::from([
("light".into(), "--a: #fff;".into()),
("dark".into(), "--a: #000;".into()),
]),
};
inject_into_state(&mut state, &resolved);
assert_eq!(state["tokens"]["light"], "--a: #fff;");
assert_eq!(state["tokens"]["dark"], "--a: #000;");
}
#[test]
fn inject_preserves_existing_state() {
let mut state = serde_json::json!({
"title": "My App",
"count": 42
});
let resolved = ResolvedTokens {
css: HashMap::from([("light".into(), "--a: #fff;".into())]),
};
inject_into_state(&mut state, &resolved);
assert_eq!(state["title"], "My App");
assert_eq!(state["count"], 42);
assert_eq!(state["tokens"]["light"], "--a: #fff;");
}
#[test]
fn inject_merges_with_existing_tokens() {
let mut state = serde_json::json!({
"tokens": { "existing": "preserved" }
});
let resolved = ResolvedTokens {
css: HashMap::from([("light".into(), "--a: #fff;".into())]),
};
inject_into_state(&mut state, &resolved);
assert_eq!(state["tokens"]["existing"], "preserved");
assert_eq!(state["tokens"]["light"], "--a: #fff;");
}
#[test]
fn resolve_diamond_dependency() {
let token_file = TokenFile {
themes: HashMap::from([(
"light".into(),
HashMap::from([
("a".into(), "var(--b) var(--c)".into()),
("b".into(), "var(--d)".into()),
("c".into(), "var(--d)".into()),
("d".into(), "#000".into()),
]),
)]),
};
let protocol_tokens = vec!["a".into()];
let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
let css = &resolved.css["light"];
assert!(css.contains("--d: #000;"), "d should be included: {css}");
assert!(css.contains("--b:"), "b should be included: {css}");
assert!(css.contains("--c:"), "c should be included: {css}");
}
}