Skip to main content

webui_tokens/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Design token loading, filtering, and CSS generation for WebUI.
5//!
6//! Loads token values from JSON files, filters them against the protocol's token
7//! list, follows present transitive `var(--x)` dependencies for tokens in each
8//! theme, and generates per-theme CSS declaration strings ready for state
9//! injection. Theme token internals are trusted: unresolved or cyclic
10//! dependencies remain browser CSS semantics rather than build failures.
11//!
12//! # Token file format
13//!
14//! Single multi-theme JSON:
15//!
16//! ```json
17//! {
18//!   "themes": {
19//!     "light": { "surface-page": "#ffffff", "text-primary": "#111827" },
20//!     "dark":  { "surface-page": "#171717", "text-primary": "#fafafa" }
21//!   }
22//! }
23//! ```
24//!
25//! Or flat single-theme JSON (treated as a single `"default"` theme):
26//!
27//! ```json
28//! { "surface-page": "#ffffff", "text-primary": "#111827" }
29//! ```
30
31mod error;
32
33pub use error::{Result, TokenError};
34
35use serde_json::Value;
36use std::collections::{HashMap, HashSet};
37use std::path::{Path, PathBuf};
38
39/// Loaded token themes — maps theme name → (token name → CSS value).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct TokenFile {
42    /// Theme name → token map. Token names are without the `--` prefix.
43    pub themes: HashMap<String, HashMap<String, String>>,
44}
45
46/// A CSS `var()` fallback chain that needs theme or local CSS coverage.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct CssFallbackChain {
49    /// Candidate custom property names in browser fallback order, without `--`.
50    pub tokens: Vec<String>,
51    /// Whether the CSS chain ends in a non-token literal fallback.
52    ///
53    /// When true, the chain is valid even if no candidate token exists in a
54    /// theme because the browser can use the literal fallback.
55    pub has_literal_fallback: bool,
56}
57
58/// Result of resolving tokens: per-theme CSS strings.
59#[derive(Debug, Clone)]
60pub struct ResolvedTokens {
61    /// Theme name → CSS declarations string (e.g. `"--surface-page: #fff;\n--text-primary: #111;"`)
62    pub css: HashMap<String, String>,
63}
64
65// ── Loading ──────────────────────────────────────────────────────────
66
67/// Load a multi-theme token file.
68///
69/// Expects either:
70/// - `{ "themes": { "light": { ... }, "dark": { ... } } }` (multi-theme)
71/// - `{ "token-name": "value", ... }` (flat — treated as a single `"default"` theme)
72///
73/// # Errors
74///
75/// Returns [`TokenError::Io`] if the file cannot be read, [`TokenError::InvalidJson`]
76/// if parsing fails, or [`TokenError::Schema`] if the structure is invalid.
77pub fn load_token_file(path: &Path) -> Result<TokenFile> {
78    let content = std::fs::read_to_string(path).map_err(|e| TokenError::io(path, e))?;
79    parse_token_content(&content, path)
80}
81
82/// Parse token JSON content into a [`TokenFile`].
83///
84/// # Errors
85///
86/// Returns [`TokenError::InvalidJson`] or [`TokenError::Schema`] on bad input.
87pub fn parse_token_content(content: &str, source_path: &Path) -> Result<TokenFile> {
88    let value: Value =
89        serde_json::from_str(content).map_err(|e| TokenError::json(source_path, e))?;
90
91    let obj = value
92        .as_object()
93        .ok_or_else(|| TokenError::Schema("Token file must be a JSON object".into()))?;
94
95    // Multi-theme format: { "themes": { ... } }
96    if let Some(themes_val) = obj.get("themes") {
97        let themes_obj = themes_val
98            .as_object()
99            .ok_or_else(|| TokenError::Schema("\"themes\" must be an object".into()))?;
100
101        if themes_obj.is_empty() {
102            return Err(TokenError::Schema(
103                "\"themes\" must contain at least one theme".into(),
104            ));
105        }
106
107        let mut themes = HashMap::with_capacity(themes_obj.len());
108        for (theme_name, theme_val) in themes_obj {
109            let tokens = parse_flat_token_map(theme_val, theme_name)?;
110            themes.insert(theme_name.clone(), tokens);
111        }
112        return Ok(TokenFile { themes });
113    }
114
115    // Flat format: { "token-name": "value", ... } → single "default" theme
116    // Skip $schema key if present
117    let tokens = parse_flat_token_map(&value, "default")?;
118    if tokens.is_empty() {
119        return Err(TokenError::Schema(
120            "Token file contains no token definitions".into(),
121        ));
122    }
123    let mut themes = HashMap::with_capacity(1);
124    themes.insert("default".into(), tokens);
125    Ok(TokenFile { themes })
126}
127
128/// Parse a JSON value as a flat `{ "name": "value" }` token map.
129fn parse_flat_token_map(value: &Value, theme_name: &str) -> Result<HashMap<String, String>> {
130    let obj = value
131        .as_object()
132        .ok_or_else(|| TokenError::Schema(format!("Theme '{theme_name}' must be a JSON object")))?;
133
134    let mut tokens = HashMap::with_capacity(obj.len());
135    for (key, val) in obj {
136        // Skip metadata keys like $schema
137        if key.starts_with('$') {
138            continue;
139        }
140        // Skip nested "themes" key (already handled at top level)
141        if key == "themes" {
142            continue;
143        }
144        match val.as_str() {
145            Some(s) => {
146                tokens.insert(key.clone(), s.to_string());
147            }
148            None => {
149                return Err(TokenError::Schema(format!(
150                    "Token '{key}' in theme '{theme_name}' must be a string value, got {}",
151                    value_type_name(val)
152                )));
153            }
154        }
155    }
156    Ok(tokens)
157}
158
159/// Human-readable JSON type name.
160fn value_type_name(v: &Value) -> &'static str {
161    match v {
162        Value::Null => "null",
163        Value::Bool(_) => "boolean",
164        Value::Number(_) => "number",
165        Value::String(_) => "string",
166        Value::Array(_) => "array",
167        Value::Object(_) => "object",
168    }
169}
170
171// ── Theme resolution ─────────────────────────────────────────────────
172
173/// Resolve a `--theme` value to a file path on disk.
174///
175/// Resolution order:
176/// 1. If `theme` is a path to an existing file, use it directly.
177/// 2. Otherwise, treat it as an npm package name and walk up from
178///    `search_root` looking for `node_modules/<pkg>/tokens.json`:
179///    - `@scope/pkg` → `node_modules/@scope/pkg/tokens.json`
180///    - `@scope/pkg/custom.json` → `node_modules/@scope/pkg/custom.json`
181///    - `pkg` → `node_modules/pkg/tokens.json`
182///
183/// # Errors
184///
185/// Returns [`TokenError::Schema`] if the theme cannot be resolved.
186pub fn resolve_theme_path(theme: &str, search_root: &Path) -> Result<PathBuf> {
187    // Direct file path?
188    let as_path = PathBuf::from(theme);
189    if as_path.exists() {
190        return as_path
191            .canonicalize()
192            .map_err(|e| TokenError::Schema(format!("Failed to canonicalize {theme}: {e}")));
193    }
194
195    // Package name resolution: split into (package, subpath)
196    let (pkg, subpath) = if theme.starts_with('@') {
197        let parts: Vec<&str> = theme.splitn(3, '/').collect();
198        if parts.len() >= 3 {
199            let pkg = format!("{}/{}", parts[0], parts[1]);
200            (pkg, parts[2].to_string())
201        } else if parts.len() == 2 {
202            (theme.to_string(), "tokens.json".to_string())
203        } else {
204            return Err(TokenError::Schema(format!(
205                "Invalid scoped package name: {theme}"
206            )));
207        }
208    } else if let Some((pkg, sub)) = theme.split_once('/') {
209        (pkg.to_string(), sub.to_string())
210    } else {
211        (theme.to_string(), "tokens.json".to_string())
212    };
213
214    // Walk up directories looking for node_modules (standard Node resolution)
215    let mut dir = search_root;
216    loop {
217        let candidate = dir.join("node_modules").join(&pkg).join(&subpath);
218        if candidate.exists() {
219            return candidate.canonicalize().map_err(|e| {
220                TokenError::Schema(format!(
221                    "Failed to canonicalize {}: {e}",
222                    candidate.display()
223                ))
224            });
225        }
226        match dir.parent() {
227            Some(parent) => dir = parent,
228            None => break,
229        }
230    }
231
232    Err(TokenError::Schema(format!(
233        "Theme '{theme}' not found. Looked for:\n  \
234         1. File: {theme}\n  \
235         2. Package: node_modules/{pkg}/{subpath} (walked up from {})\n\n  \
236         Make sure the package is installed (pnpm install) or the path is correct.",
237        search_root.display()
238    )))
239}
240
241// ── Resolution ───────────────────────────────────────────────────────
242
243/// Resolve tokens for all themes against the protocol's token candidate list.
244///
245/// For each theme:
246/// 1. Keeps only protocol tokens that are present in that theme
247/// 2. Follows present transitive `var(--x)` dependencies
248/// 3. Generates CSS declaration strings
249///
250/// # Errors
251///
252/// Missing protocol token candidates and missing transitive dependencies are
253/// skipped. Call [`validate_required_tokens`] first when flat token presence must
254/// be strict; theme token values themselves are trusted and left to browser CSS
255/// semantics.
256pub fn resolve_tokens(
257    protocol_tokens: &[String],
258    token_file: &TokenFile,
259) -> Result<ResolvedTokens> {
260    let mut css = HashMap::with_capacity(token_file.themes.len());
261
262    for (theme_name, theme_tokens) in &token_file.themes {
263        let css_string = resolve_theme(protocol_tokens, theme_tokens);
264        css.insert(theme_name.clone(), css_string);
265    }
266
267    Ok(ResolvedTokens { css })
268}
269
270/// Validate that every required token exists in every theme.
271///
272/// The caller selects which tokens are mandatory (e.g. only the candidates of
273/// `var()` chains without a literal CSS fallback). Theme token values themselves
274/// are trusted and are not dependency-validated.
275///
276/// # Errors
277///
278/// Returns [`TokenError::MissingToken`] if a required token is not present in
279/// every theme.
280pub fn validate_required_tokens(required_tokens: &[String], token_file: &TokenFile) -> Result<()> {
281    for (theme_name, theme_tokens) in &token_file.themes {
282        validate_theme_required_tokens(required_tokens, theme_tokens, theme_name)?;
283    }
284    Ok(())
285}
286
287/// Validate the tokens required by parser `var()` fallback chains against a theme.
288///
289/// A token is *required* when it appears in at least one chain with **no**
290/// literal CSS fallback. A chain that terminates in a literal value
291/// (`var(--x, 16px)`) does not make its candidates required — the literal
292/// already supplies a value, so `--x` may be absent from a theme. Theme token
293/// values themselves are trusted and are not dependency-validated.
294///
295/// # Errors
296///
297/// Returns [`TokenError::MissingToken`] if a required token is not present in
298/// every theme.
299pub fn validate_chain_tokens(chains: &[CssFallbackChain], token_file: &TokenFile) -> Result<()> {
300    validate_required_tokens(&required_chain_tokens(chains), token_file)
301}
302
303/// CSS tokens used **only** with a literal `var()` fallback and defined in no
304/// theme — likely typos.
305///
306/// A token referenced solely as `var(--name, <literal>)` is not a validation
307/// failure (the literal supplies a value), but when no theme defines it the
308/// reference is often a misspelling (e.g. `var(--colr-brand, #000)` for
309/// `--color-brand`). Callers may surface these as non-fatal advisories.
310/// Returned sorted and deduplicated, without the `--` prefix.
311#[must_use]
312pub fn unthemed_literal_fallback_tokens(
313    chains: &[CssFallbackChain],
314    token_file: &TokenFile,
315) -> Vec<String> {
316    let required: HashSet<&str> = chains
317        .iter()
318        .filter(|chain| !chain.has_literal_fallback)
319        .flat_map(|chain| chain.tokens.iter().map(String::as_str))
320        .collect();
321
322    let mut unthemed: Vec<String> = chains
323        .iter()
324        .flat_map(|chain| chain.tokens.iter())
325        .filter(|token| !required.contains(token.as_str()))
326        .filter(|token| {
327            token_file
328                .themes
329                .values()
330                .all(|theme| !theme.contains_key(token.as_str()))
331        })
332        .cloned()
333        .collect();
334    unthemed.sort_unstable();
335    unthemed.dedup();
336    unthemed
337}
338
339/// Collect the tokens made mandatory by `var()` chains without a literal
340/// fallback, sorted and deduplicated so validation order is deterministic.
341fn required_chain_tokens(chains: &[CssFallbackChain]) -> Vec<String> {
342    let mut required: Vec<String> = chains
343        .iter()
344        .filter(|chain| !chain.has_literal_fallback)
345        .flat_map(|chain| chain.tokens.iter().cloned())
346        .collect();
347    required.sort_unstable();
348    required.dedup();
349    required
350}
351
352/// Resolve a single theme: filter, expand deps, generate CSS.
353fn resolve_theme(required_tokens: &[String], theme_tokens: &HashMap<String, String>) -> String {
354    let present_tokens: Vec<String> = required_tokens
355        .iter()
356        .filter(|token| theme_tokens.contains_key(*token))
357        .cloned()
358        .collect();
359    let closure = theme_token_closure(&present_tokens, theme_tokens);
360    generate_css_declarations(&closure, theme_tokens)
361}
362
363fn validate_theme_required_tokens(
364    required_tokens: &[String],
365    theme_tokens: &HashMap<String, String>,
366    theme_name: &str,
367) -> Result<()> {
368    for name in required_tokens {
369        if !theme_tokens.contains_key(name) {
370            return Err(TokenError::MissingToken {
371                theme: theme_name.into(),
372                token: name.clone(),
373            });
374        }
375    }
376    Ok(())
377}
378
379fn theme_token_closure(
380    required_tokens: &[String],
381    theme_tokens: &HashMap<String, String>,
382) -> Vec<String> {
383    let required: HashSet<&str> = required_tokens.iter().map(String::as_str).collect();
384    compute_token_closure(&required, theme_tokens)
385}
386
387/// Compute the transitive closure of required tokens by following present
388/// `var(--x)` references in token values.
389fn compute_token_closure(
390    required: &HashSet<&str>,
391    theme_tokens: &HashMap<String, String>,
392) -> Vec<String> {
393    let mut closure: HashSet<String> = HashSet::new();
394    let mut queue: Vec<String> = required
395        .iter()
396        .filter(|name| theme_tokens.contains_key(**name))
397        .map(|s| (*s).into())
398        .collect();
399
400    while let Some(name) = queue.pop() {
401        if !closure.insert(name.clone()) {
402            continue;
403        }
404
405        if let Some(value) = theme_tokens.get(&name) {
406            let deps = extract_var_references(value);
407            for dep in deps {
408                if theme_tokens.contains_key(&dep) && !closure.contains(&dep) {
409                    queue.push(dep);
410                }
411            }
412        }
413    }
414
415    let mut sorted: Vec<String> = closure.into_iter().collect();
416    sorted.sort();
417    sorted
418}
419
420// ── var(--x) extraction (no regex — deterministic scanner) ───────────
421
422/// Extract `var(--name)` references from a CSS value string.
423///
424/// Uses a simple scanner instead of regex per project guidelines.
425/// Handles nested `var()` calls and `var(--name, fallback)` syntax.
426#[must_use]
427pub fn extract_var_references(value: &str) -> Vec<String> {
428    let mut refs = Vec::new();
429    let bytes = value.as_bytes();
430    let len = bytes.len();
431    let mut i = 0;
432
433    while i + 5 < len {
434        // Look for "var(" prefix
435        if bytes[i] == b'v' && bytes[i + 1] == b'a' && bytes[i + 2] == b'r' && bytes[i + 3] == b'('
436        {
437            i += 4;
438            // Skip whitespace
439            while i < len && bytes[i].is_ascii_whitespace() {
440                i += 1;
441            }
442            // Expect "--"
443            if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
444                i += 2;
445                let start = i;
446                // Read name: alphanumeric + hyphens
447                while i < len
448                    && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
449                {
450                    i += 1;
451                }
452                if i > start {
453                    let name = &value[start..i];
454                    refs.push(name.into());
455                }
456            }
457        } else {
458            i += 1;
459        }
460    }
461
462    refs
463}
464
465// ── CSS generation ───────────────────────────────────────────────────
466
467/// Generate a CSS declarations string from the resolved token closure.
468///
469/// Tokens are sorted alphabetically for deterministic output. Each token
470/// becomes a `--name: value;` declaration.
471fn generate_css_declarations(closure: &[String], theme_tokens: &HashMap<String, String>) -> String {
472    // Pre-calculate capacity: each line is roughly "--name: value;\n"
473    let estimated_capacity: usize = closure
474        .iter()
475        .map(|name| {
476            // "--" + name + ": " + value + ";\n"
477            4 + name.len() + theme_tokens.get(name).map_or(0, |v| v.len()) + 2
478        })
479        .sum();
480
481    let mut css = String::with_capacity(estimated_capacity);
482    for (idx, name) in closure.iter().enumerate() {
483        if let Some(value) = theme_tokens.get(name) {
484            if idx > 0 {
485                css.push('\n');
486            }
487            css.push_str("--");
488            css.push_str(name);
489            css.push_str(": ");
490            css.push_str(value);
491            css.push(';');
492        }
493    }
494    css
495}
496
497/// Inject resolved token CSS into a JSON state value under `state.tokens`.
498///
499/// Sets `state["tokens"]["<theme>"] = "<css string>"` for each theme.
500/// Creates the `"tokens"` object if it doesn't exist.
501///
502/// Silently returns without modification if `state` is not a JSON object.
503pub fn inject_into_state(state: &mut Value, resolved: &ResolvedTokens) {
504    let Some(state_map) = state.as_object_mut() else {
505        return;
506    };
507
508    let tokens_obj = state_map
509        .entry("tokens")
510        .or_insert_with(|| Value::Object(serde_json::Map::new()));
511
512    if let Some(map) = tokens_obj.as_object_mut() {
513        for (theme_name, css_string) in &resolved.css {
514            map.insert(theme_name.clone(), Value::String(css_string.clone()));
515        }
516    }
517}
518
519/// Inject pre-resolved per-theme CSS strings into a JSON state value.
520///
521/// This is the render-time equivalent of [`inject_into_state`] that works
522/// with the pre-resolved `HashMap<String, String>` stored in server context
523/// rather than re-resolving from a [`TokenFile`] each time.
524///
525/// Silently returns without modification if `state` is not a JSON object.
526pub fn inject_token_css(state: &mut Value, token_css: &HashMap<String, String>) {
527    let Some(state_map) = state.as_object_mut() else {
528        return;
529    };
530
531    let tokens_obj = state_map
532        .entry("tokens")
533        .or_insert_with(|| Value::Object(serde_json::Map::new()));
534
535    if let Some(map) = tokens_obj.as_object_mut() {
536        for (theme, css) in token_css {
537            map.insert(theme.clone(), Value::String(css.clone()));
538        }
539    }
540}
541
542// ── Tests ────────────────────────────────────────────────────────────
543
544#[cfg(test)]
545#[allow(clippy::disallowed_methods)]
546mod tests {
547    use super::*;
548    use std::io::Write;
549    use tempfile::NamedTempFile;
550
551    // ── var(--x) extraction tests ────────────────────────────────────
552
553    #[test]
554    fn extract_var_refs_simple() {
555        let refs = extract_var_references("var(--color-primary)");
556        assert_eq!(refs, vec!["color-primary"]);
557    }
558
559    #[test]
560    fn extract_var_refs_multiple() {
561        let refs =
562            extract_var_references("color-mix(in oklch, var(--accent) 15%, var(--background))");
563        assert_eq!(refs, vec!["accent", "background"]);
564    }
565
566    #[test]
567    fn extract_var_refs_with_fallback() {
568        let refs = extract_var_references("var(--color-brand, #0078d4)");
569        assert_eq!(refs, vec!["color-brand"]);
570    }
571
572    #[test]
573    fn extract_var_refs_nested() {
574        let refs = extract_var_references("var(--a, var(--b))");
575        assert_eq!(refs, vec!["a", "b"]);
576    }
577
578    #[test]
579    fn extract_var_refs_with_whitespace() {
580        let refs = extract_var_references("var( --spaced-name )");
581        assert_eq!(refs, vec!["spaced-name"]);
582    }
583
584    #[test]
585    fn extract_var_refs_no_refs() {
586        let refs = extract_var_references("#0078d4");
587        assert!(refs.is_empty());
588    }
589
590    #[test]
591    fn extract_var_refs_underscore_name() {
592        let refs = extract_var_references("var(--my_token)");
593        assert_eq!(refs, vec!["my_token"]);
594    }
595
596    #[test]
597    fn extract_var_refs_calc_expression() {
598        let refs = extract_var_references("calc(var(--spacing-m) * 2)");
599        assert_eq!(refs, vec!["spacing-m"]);
600    }
601
602    #[test]
603    fn extract_var_refs_empty_string() {
604        let refs = extract_var_references("");
605        assert!(refs.is_empty());
606    }
607
608    #[test]
609    fn extract_var_refs_short_string() {
610        let refs = extract_var_references("abc");
611        assert!(refs.is_empty());
612    }
613
614    // ── Token file loading tests ─────────────────────────────────────
615
616    /// Write a JSON value to a temp file and return it.
617    fn write_json_file(value: &Value) -> NamedTempFile {
618        let mut file = NamedTempFile::new().unwrap();
619        serde_json::to_writer(&mut file, value).unwrap();
620        file
621    }
622
623    #[test]
624    fn load_multi_theme_file() {
625        let json = serde_json::json!({
626            "themes": {
627                "light": { "surface-page": "#ffffff", "text-primary": "#111827" },
628                "dark": { "surface-page": "#171717", "text-primary": "#fafafa" }
629            }
630        });
631        let file = write_json_file(&json);
632
633        let tokens = load_token_file(file.path()).unwrap();
634        assert_eq!(tokens.themes.len(), 2);
635        assert_eq!(tokens.themes["light"]["surface-page"], "#ffffff");
636        assert_eq!(tokens.themes["dark"]["text-primary"], "#fafafa");
637    }
638
639    #[test]
640    fn load_flat_single_theme_file() {
641        let json = serde_json::json!({
642            "surface-page": "#ffffff",
643            "text-primary": "#111827"
644        });
645        let file = write_json_file(&json);
646
647        let tokens = load_token_file(file.path()).unwrap();
648        assert_eq!(tokens.themes.len(), 1);
649        assert!(tokens.themes.contains_key("default"));
650        assert_eq!(tokens.themes["default"]["surface-page"], "#ffffff");
651    }
652
653    #[test]
654    fn load_file_with_schema_key() {
655        let json = serde_json::json!({
656            "$schema": "https://webui.dev/schemas/tokens.json",
657            "themes": {
658                "light": { "color-brand": "#0078d4" }
659            }
660        });
661        let file = write_json_file(&json);
662
663        let tokens = load_token_file(file.path()).unwrap();
664        assert_eq!(tokens.themes["light"]["color-brand"], "#0078d4");
665    }
666
667    #[test]
668    fn load_empty_themes_returns_error() {
669        let json = serde_json::json!({ "themes": {} });
670        let file = write_json_file(&json);
671
672        let result = load_token_file(file.path());
673        assert!(matches!(result, Err(TokenError::Schema(_))));
674    }
675
676    #[test]
677    fn load_non_string_value_returns_error() {
678        let json = serde_json::json!({ "themes": { "light": { "bad": 42 } } });
679        let file = write_json_file(&json);
680
681        let result = load_token_file(file.path());
682        assert!(
683            matches!(result, Err(TokenError::Schema(ref msg)) if msg.contains("must be a string")),
684            "expected schema error, got: {result:?}"
685        );
686    }
687
688    #[test]
689    fn load_missing_file_returns_io_error() {
690        let result = load_token_file(Path::new("/nonexistent/tokens.json"));
691        assert!(matches!(result, Err(TokenError::Io { .. })));
692    }
693
694    #[test]
695    fn load_invalid_json_returns_error() {
696        let mut file = NamedTempFile::new().unwrap();
697        file.write_all(b"{ not json }").unwrap();
698
699        let result = load_token_file(file.path());
700        assert!(matches!(result, Err(TokenError::InvalidJson { .. })));
701    }
702
703    // ── Resolution tests ─────────────────────────────────────────────
704
705    #[test]
706    fn resolve_filters_to_protocol_tokens() {
707        let token_file = TokenFile {
708            themes: HashMap::from([(
709                "light".into(),
710                HashMap::from([
711                    ("surface-page".into(), "#ffffff".into()),
712                    ("text-primary".into(), "#111827".into()),
713                    ("unused-token".into(), "#999999".into()),
714                ]),
715            )]),
716        };
717
718        let protocol_tokens = vec!["surface-page".into(), "text-primary".into()];
719        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
720
721        let css = &resolved.css["light"];
722        assert!(css.contains("--surface-page: #ffffff;"));
723        assert!(css.contains("--text-primary: #111827;"));
724        assert!(!css.contains("unused-token"));
725    }
726
727    #[test]
728    fn resolve_expands_transitive_dependencies() {
729        let token_file = TokenFile {
730            themes: HashMap::from([(
731                "light".into(),
732                HashMap::from([
733                    ("surface-page".into(), "var(--neutral-100)".into()),
734                    ("neutral-100".into(), "#f9fafb".into()),
735                ]),
736            )]),
737        };
738
739        let protocol_tokens = vec!["surface-page".into()];
740        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
741
742        let css = &resolved.css["light"];
743        assert!(
744            css.contains("--neutral-100: #f9fafb;"),
745            "transitive dep should be included: {css}"
746        );
747        assert!(css.contains("--surface-page: var(--neutral-100);"));
748    }
749
750    #[test]
751    fn resolve_deep_transitive_chain() {
752        let token_file = TokenFile {
753            themes: HashMap::from([(
754                "light".into(),
755                HashMap::from([
756                    ("a".into(), "var(--b)".into()),
757                    ("b".into(), "var(--c)".into()),
758                    ("c".into(), "#000".into()),
759                ]),
760            )]),
761        };
762
763        let protocol_tokens = vec!["a".into()];
764        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
765        let css = &resolved.css["light"];
766
767        assert!(css.contains("--a: var(--b);"), "a should be in CSS: {css}");
768        assert!(css.contains("--b: var(--c);"), "b should be in CSS: {css}");
769        assert!(css.contains("--c: #000;"), "c should be in CSS: {css}");
770    }
771
772    #[test]
773    fn resolve_trusts_direct_cycle() {
774        let token_file = TokenFile {
775            themes: HashMap::from([(
776                "light".into(),
777                HashMap::from([
778                    ("a".into(), "var(--b)".into()),
779                    ("b".into(), "var(--a)".into()),
780                ]),
781            )]),
782        };
783
784        let protocol_tokens = vec!["a".into()];
785        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
786        let css = &resolved.css["light"];
787
788        assert!(
789            css.contains("--a: var(--b);"),
790            "a should be included: {css}"
791        );
792        assert!(
793            css.contains("--b: var(--a);"),
794            "b should be included: {css}"
795        );
796    }
797
798    #[test]
799    fn resolve_trusts_self_referencing_cycle() {
800        let token_file = TokenFile {
801            themes: HashMap::from([(
802                "light".into(),
803                HashMap::from([("a".into(), "var(--a)".into())]),
804            )]),
805        };
806
807        let protocol_tokens = vec!["a".into()];
808        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
809        assert_eq!(resolved.css["light"], "--a: var(--a);");
810    }
811
812    #[test]
813    fn resolve_skips_missing_protocol_token() {
814        let token_file = TokenFile {
815            themes: HashMap::from([(
816                "light".into(),
817                HashMap::from([("surface-page".into(), "#fff".into())]),
818            )]),
819        };
820
821        let protocol_tokens = vec!["surface-page".into(), "nonexistent".into()];
822        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
823
824        assert_eq!(resolved.css["light"], "--surface-page: #fff;");
825    }
826
827    #[test]
828    fn resolve_trusts_missing_dependency() {
829        let token_file = TokenFile {
830            themes: HashMap::from([(
831                "light".into(),
832                HashMap::from([("surface-page".into(), "var(--not-defined)".into())]),
833            )]),
834        };
835
836        let protocol_tokens = vec!["surface-page".into()];
837        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
838
839        assert_eq!(resolved.css["light"], "--surface-page: var(--not-defined);");
840    }
841
842    #[test]
843    fn validate_required_tokens_accepts_complete_theme() {
844        let token_file = TokenFile {
845            themes: HashMap::from([(
846                "light".into(),
847                HashMap::from([
848                    ("surface-page".into(), "var(--neutral-100)".into()),
849                    ("neutral-100".into(), "#fff".into()),
850                ]),
851            )]),
852        };
853
854        let protocol_tokens = vec!["surface-page".into()];
855        validate_required_tokens(&protocol_tokens, &token_file).unwrap();
856    }
857
858    #[test]
859    fn validate_required_tokens_errors_on_missing_token() {
860        let token_file = TokenFile {
861            themes: HashMap::from([("light".into(), HashMap::new())]),
862        };
863
864        let protocol_tokens = vec!["surface-page".into()];
865        let result = validate_required_tokens(&protocol_tokens, &token_file);
866        assert!(
867            matches!(result, Err(TokenError::MissingToken { ref token, .. }) if token == "surface-page"),
868            "expected missing token error, got: {result:?}"
869        );
870    }
871
872    fn chain(tokens: &[&str], has_literal_fallback: bool) -> CssFallbackChain {
873        CssFallbackChain {
874            tokens: tokens.iter().map(|t| (*t).to_string()).collect(),
875            has_literal_fallback,
876        }
877    }
878
879    #[test]
880    fn validate_chain_tokens_requires_all_non_literal_candidates() {
881        // `var(--token-a, var(--token-b))` with no literal fallback requires both.
882        let token_file = TokenFile {
883            themes: HashMap::from([(
884                "light".into(),
885                HashMap::from([("token-a".into(), "red".into())]),
886            )]),
887        };
888        let chains = vec![chain(&["token-a", "token-b"], false)];
889
890        let result = validate_chain_tokens(&chains, &token_file);
891        assert!(
892            matches!(result, Err(TokenError::MissingToken { ref token, .. }) if token == "token-b"),
893            "expected token-b missing, got: {result:?}"
894        );
895    }
896
897    #[test]
898    fn validate_chain_tokens_exempts_literal_fallback_chain() {
899        // `var(--brand, #000)` must pass even when no theme defines `--brand`.
900        let token_file = TokenFile {
901            themes: HashMap::from([("light".into(), HashMap::new())]),
902        };
903        let chains = vec![chain(&["brand"], true)];
904
905        validate_chain_tokens(&chains, &token_file).unwrap();
906    }
907
908    #[test]
909    fn validate_chain_tokens_requires_token_with_any_bare_usage() {
910        // The same token used once with and once without a literal fallback is
911        // required because of the bare usage.
912        let token_file = TokenFile {
913            themes: HashMap::from([("light".into(), HashMap::new())]),
914        };
915        let chains = vec![chain(&["brand"], true), chain(&["brand"], false)];
916
917        let result = validate_chain_tokens(&chains, &token_file);
918        assert!(
919            matches!(result, Err(TokenError::MissingToken { ref token, .. }) if token == "brand"),
920            "expected brand required, got: {result:?}"
921        );
922    }
923
924    #[test]
925    fn unthemed_literal_fallback_tokens_flags_only_literal_only_absent_tokens() {
926        let token_file = TokenFile {
927            themes: HashMap::from([(
928                "light".into(),
929                HashMap::from([
930                    ("present".into(), "2px".into()),
931                    ("required".into(), "8px".into()),
932                ]),
933            )]),
934        };
935        let chains = vec![
936            chain(&["colr-brand"], true), // literal-only, absent → flagged
937            chain(&["present"], true),    // literal-only but themed → not flagged
938            chain(&["required"], false),  // no literal fallback → not an advisory
939        ];
940
941        assert_eq!(
942            unthemed_literal_fallback_tokens(&chains, &token_file),
943            vec!["colr-brand".to_string()]
944        );
945    }
946
947    #[test]
948    fn unthemed_literal_fallback_tokens_does_not_flag_token_with_bare_usage() {
949        let token_file = TokenFile {
950            themes: HashMap::from([("light".into(), HashMap::new())]),
951        };
952        // Used both with and without a literal fallback → required, not advisory.
953        let chains = vec![chain(&["brand"], true), chain(&["brand"], false)];
954
955        assert!(unthemed_literal_fallback_tokens(&chains, &token_file).is_empty());
956    }
957
958    #[test]
959    fn resolve_multiple_themes() {
960        let token_file = TokenFile {
961            themes: HashMap::from([
962                (
963                    "light".into(),
964                    HashMap::from([("surface-page".into(), "#ffffff".into())]),
965                ),
966                (
967                    "dark".into(),
968                    HashMap::from([("surface-page".into(), "#171717".into())]),
969                ),
970            ]),
971        };
972
973        let protocol_tokens = vec!["surface-page".into()];
974        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
975
976        assert_eq!(resolved.css["light"], "--surface-page: #ffffff;");
977        assert_eq!(resolved.css["dark"], "--surface-page: #171717;");
978    }
979
980    #[test]
981    fn resolve_empty_protocol_tokens_produces_empty_css() {
982        let token_file = TokenFile {
983            themes: HashMap::from([(
984                "light".into(),
985                HashMap::from([("surface-page".into(), "#fff".into())]),
986            )]),
987        };
988
989        let resolved = resolve_tokens(&[], &token_file).unwrap();
990        assert_eq!(resolved.css["light"], "");
991    }
992
993    #[test]
994    fn resolve_complex_css_values() {
995        let token_file = TokenFile {
996            themes: HashMap::from([(
997                "light".into(),
998                HashMap::from([
999                    ("shadow-card".into(), "0 8px 32px rgba(0,0,0,0.3)".into()),
1000                    (
1001                        "accent-subtle".into(),
1002                        "color-mix(in oklch, var(--accent) 15%, transparent)".into(),
1003                    ),
1004                    ("accent".into(), "#0078d4".into()),
1005                ]),
1006            )]),
1007        };
1008
1009        let protocol_tokens = vec!["shadow-card".into(), "accent-subtle".into()];
1010        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
1011        let css = &resolved.css["light"];
1012
1013        assert!(
1014            css.contains("--accent: #0078d4;"),
1015            "transitive dep 'accent' should be included: {css}"
1016        );
1017        assert!(css.contains("--shadow-card: 0 8px 32px rgba(0,0,0,0.3);"));
1018    }
1019
1020    #[test]
1021    fn resolve_css_output_is_sorted() {
1022        let token_file = TokenFile {
1023            themes: HashMap::from([(
1024                "light".into(),
1025                HashMap::from([
1026                    ("z-token".into(), "z".into()),
1027                    ("a-token".into(), "a".into()),
1028                    ("m-token".into(), "m".into()),
1029                ]),
1030            )]),
1031        };
1032
1033        let protocol_tokens = vec!["z-token".into(), "a-token".into(), "m-token".into()];
1034        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
1035        let css = &resolved.css["light"];
1036
1037        let lines: Vec<&str> = css.lines().collect();
1038        assert_eq!(lines[0], "--a-token: a;");
1039        assert_eq!(lines[1], "--m-token: m;");
1040        assert_eq!(lines[2], "--z-token: z;");
1041    }
1042
1043    // ── State injection tests ────────────────────────────────────────
1044
1045    #[test]
1046    fn inject_into_empty_state() {
1047        let mut state = serde_json::json!({});
1048        let resolved = ResolvedTokens {
1049            css: HashMap::from([
1050                ("light".into(), "--a: #fff;".into()),
1051                ("dark".into(), "--a: #000;".into()),
1052            ]),
1053        };
1054
1055        inject_into_state(&mut state, &resolved);
1056
1057        assert_eq!(state["tokens"]["light"], "--a: #fff;");
1058        assert_eq!(state["tokens"]["dark"], "--a: #000;");
1059    }
1060
1061    #[test]
1062    fn inject_preserves_existing_state() {
1063        let mut state = serde_json::json!({
1064            "title": "My App",
1065            "count": 42
1066        });
1067        let resolved = ResolvedTokens {
1068            css: HashMap::from([("light".into(), "--a: #fff;".into())]),
1069        };
1070
1071        inject_into_state(&mut state, &resolved);
1072
1073        assert_eq!(state["title"], "My App");
1074        assert_eq!(state["count"], 42);
1075        assert_eq!(state["tokens"]["light"], "--a: #fff;");
1076    }
1077
1078    #[test]
1079    fn inject_merges_with_existing_tokens() {
1080        let mut state = serde_json::json!({
1081            "tokens": { "existing": "preserved" }
1082        });
1083        let resolved = ResolvedTokens {
1084            css: HashMap::from([("light".into(), "--a: #fff;".into())]),
1085        };
1086
1087        inject_into_state(&mut state, &resolved);
1088
1089        assert_eq!(state["tokens"]["existing"], "preserved");
1090        assert_eq!(state["tokens"]["light"], "--a: #fff;");
1091    }
1092
1093    // ── Edge case: diamond dependency ────────────────────────────────
1094
1095    #[test]
1096    fn resolve_diamond_dependency() {
1097        // a → b, a → c, b → d, c → d (diamond, not a cycle)
1098        let token_file = TokenFile {
1099            themes: HashMap::from([(
1100                "light".into(),
1101                HashMap::from([
1102                    ("a".into(), "var(--b) var(--c)".into()),
1103                    ("b".into(), "var(--d)".into()),
1104                    ("c".into(), "var(--d)".into()),
1105                    ("d".into(), "#000".into()),
1106                ]),
1107            )]),
1108        };
1109
1110        let protocol_tokens = vec!["a".into()];
1111        let resolved = resolve_tokens(&protocol_tokens, &token_file).unwrap();
1112        let css = &resolved.css["light"];
1113
1114        assert!(css.contains("--d: #000;"), "d should be included: {css}");
1115        assert!(css.contains("--b:"), "b should be included: {css}");
1116        assert!(css.contains("--c:"), "c should be included: {css}");
1117    }
1118}