1mod error;
32
33pub use error::{Result, TokenError};
34
35use serde_json::Value;
36use std::collections::{HashMap, HashSet};
37use std::path::{Path, PathBuf};
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct TokenFile {
42 pub themes: HashMap<String, HashMap<String, String>>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct CssFallbackChain {
49 pub tokens: Vec<String>,
51 pub has_literal_fallback: bool,
56}
57
58#[derive(Debug, Clone)]
60pub struct ResolvedTokens {
61 pub css: HashMap<String, String>,
63}
64
65pub 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
82pub 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 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 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
128fn 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 if key.starts_with('$') {
138 continue;
139 }
140 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
159fn 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
171pub fn resolve_theme_path(theme: &str, search_root: &Path) -> Result<PathBuf> {
187 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 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 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
241pub 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
270pub 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
287pub fn validate_chain_tokens(chains: &[CssFallbackChain], token_file: &TokenFile) -> Result<()> {
300 validate_required_tokens(&required_chain_tokens(chains), token_file)
301}
302
303#[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
339fn 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
352fn 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
387fn 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#[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 if bytes[i] == b'v' && bytes[i + 1] == b'a' && bytes[i + 2] == b'r' && bytes[i + 3] == b'('
436 {
437 i += 4;
438 while i < len && bytes[i].is_ascii_whitespace() {
440 i += 1;
441 }
442 if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
444 i += 2;
445 let start = i;
446 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
465fn generate_css_declarations(closure: &[String], theme_tokens: &HashMap<String, String>) -> String {
472 let estimated_capacity: usize = closure
474 .iter()
475 .map(|name| {
476 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
497pub 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
519pub 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#[cfg(test)]
545#[allow(clippy::disallowed_methods)]
546mod tests {
547 use super::*;
548 use std::io::Write;
549 use tempfile::NamedTempFile;
550
551 #[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 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 #[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 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 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 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), chain(&["present"], true), chain(&["required"], false), ];
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 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 #[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 #[test]
1096 fn resolve_diamond_dependency() {
1097 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}