1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, Default)]
8pub struct DesignTokens {
9 pub entries: BTreeMap<String, TokenNode>,
10}
11
12#[derive(Debug, Clone)]
14pub enum TokenNode {
15 Token(Token),
16 Group(TokenGroup),
17}
18
19#[derive(Debug, Clone)]
21pub struct Token {
22 pub value: TokenValue,
23 pub token_type: Option<String>,
24 pub description: Option<String>,
25 pub extensions: Option<Value>,
26}
27
28#[derive(Debug, Clone)]
30pub struct TokenGroup {
31 pub group_type: Option<String>,
32 pub description: Option<String>,
33 pub children: BTreeMap<String, TokenNode>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(untagged)]
39pub enum TokenValue {
40 String(String),
41 Number(f64),
42 Bool(bool),
43 Array(Vec<Value>),
44 Object(CompositeValue),
45}
46
47pub type CompositeValue = BTreeMap<String, Value>;
49
50fn parse_node(value: &Value) -> Option<TokenNode> {
51 let obj = value.as_object()?;
52
53 if obj.contains_key("$value") {
54 let token_value: TokenValue = serde_json::from_value(obj["$value"].clone()).ok()?;
55 Some(TokenNode::Token(Token {
56 value: token_value,
57 token_type: obj.get("$type").and_then(|v| v.as_str()).map(String::from),
58 description: obj
59 .get("$description")
60 .and_then(|v| v.as_str())
61 .map(String::from),
62 extensions: obj.get("$extensions").cloned(),
63 }))
64 } else {
65 let mut children = BTreeMap::new();
66 for (key, val) in obj {
67 if key.starts_with('$') {
68 continue;
69 }
70 if let Some(node) = parse_node(val) {
71 children.insert(key.clone(), node);
72 }
73 }
74 Some(TokenNode::Group(TokenGroup {
75 group_type: obj.get("$type").and_then(|v| v.as_str()).map(String::from),
76 description: obj
77 .get("$description")
78 .and_then(|v| v.as_str())
79 .map(String::from),
80 children,
81 }))
82 }
83}
84
85impl DesignTokens {
86 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
87 let raw: Value = serde_json::from_str(json)?;
88 let obj = raw.as_object().cloned().unwrap_or_default();
89
90 let mut entries = BTreeMap::new();
91 for (key, val) in &obj {
92 if key.starts_with('$') {
93 continue;
94 }
95 if let Some(node) = parse_node(val) {
96 entries.insert(key.clone(), node);
97 }
98 }
99
100 Ok(Self { entries })
101 }
102
103 pub fn from_file(path: &camino::Utf8Path) -> Result<Self, Box<dyn std::error::Error>> {
104 let content = std::fs::read_to_string(path)?;
105 Ok(Self::from_json(&content)?)
106 }
107
108 pub fn flatten(&self) -> BTreeMap<String, FlatToken> {
111 let mut result = BTreeMap::new();
112 flatten_nodes(&self.entries, "", None, &mut result);
113 result
114 }
115}
116
117#[derive(Debug, Clone)]
119pub struct FlatToken {
120 pub path: String,
121 pub value: TokenValue,
122 pub token_type: Option<String>,
123 pub description: Option<String>,
124}
125
126fn flatten_nodes(
127 entries: &BTreeMap<String, TokenNode>,
128 prefix: &str,
129 inherited_type: Option<&str>,
130 out: &mut BTreeMap<String, FlatToken>,
131) {
132 for (key, node) in entries {
133 if key.starts_with('$') {
134 continue;
135 }
136 let raw_path = if prefix.is_empty() {
137 key.clone()
138 } else {
139 format!("{prefix}.{key}")
140 };
141 let path = if raw_path.ends_with("._") {
143 raw_path[..raw_path.len() - 2].to_string()
144 } else if key == "_" && !prefix.is_empty() {
145 prefix.to_string()
146 } else {
147 raw_path
148 };
149
150 match node {
151 TokenNode::Token(token) => {
152 let token_type = token
153 .token_type
154 .as_deref()
155 .or(inherited_type)
156 .map(|s| s.to_string());
157 out.insert(
158 path.clone(),
159 FlatToken {
160 path,
161 value: token.value.clone(),
162 token_type,
163 description: token.description.clone(),
164 },
165 );
166 }
167 TokenNode::Group(group) => {
168 let group_type = group.group_type.as_deref().or(inherited_type);
169 flatten_nodes(&group.children, &path, group_type, out);
170 }
171 }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn parse_simple_tokens() {
181 let json = r##"{
182 "color": {
183 "$type": "color",
184 "primary": { "$value": "#0066cc", "$description": "Brand color" },
185 "text": { "$value": "#1a1a1a" }
186 }
187 }"##;
188
189 let tokens = DesignTokens::from_json(json).unwrap();
190 let flat = tokens.flatten();
191
192 assert_eq!(flat.len(), 2);
193 assert!(flat.contains_key("color.primary"));
194 assert!(flat.contains_key("color.text"));
195 assert_eq!(flat["color.primary"].token_type.as_deref(), Some("color"));
196 assert_eq!(flat["color.text"].token_type.as_deref(), Some("color"));
197 }
198
199 #[test]
200 fn parse_composite_tokens() {
201 let json = r##"{
202 "spacing": {
203 "$type": "dimension",
204 "md": { "$value": { "value": 16, "unit": "px" } }
205 }
206 }"##;
207
208 let tokens = DesignTokens::from_json(json).unwrap();
209 let flat = tokens.flatten();
210
211 assert_eq!(flat.len(), 1);
212 assert_eq!(flat["spacing.md"].token_type.as_deref(), Some("dimension"));
213 }
214
215 #[test]
216 fn type_inheritance_from_group() {
217 let json = r##"{
218 "color": {
219 "$type": "color",
220 "primary": { "$value": "#0066cc" },
221 "secondary": { "$value": "#ff6b35", "$type": "color" }
222 }
223 }"##;
224
225 let tokens = DesignTokens::from_json(json).unwrap();
226 let flat = tokens.flatten();
227
228 assert_eq!(flat["color.primary"].token_type.as_deref(), Some("color"));
229 assert_eq!(flat["color.secondary"].token_type.as_deref(), Some("color"));
230 }
231
232 #[test]
233 fn nested_groups() {
234 let json = r##"{
235 "color": {
236 "$type": "color",
237 "neutral": {
238 "100": { "$value": "#f5f5f5" },
239 "900": { "$value": "#1a1a1a" }
240 }
241 }
242 }"##;
243
244 let tokens = DesignTokens::from_json(json).unwrap();
245 let flat = tokens.flatten();
246
247 assert!(flat.contains_key("color.neutral.100"));
248 assert!(flat.contains_key("color.neutral.900"));
249 }
250
251 #[test]
252 fn description_preserved() {
253 let json = r##"{
254 "color": {
255 "primary": { "$value": "#0066cc", "$description": "Main brand color" }
256 }
257 }"##;
258
259 let tokens = DesignTokens::from_json(json).unwrap();
260 let flat = tokens.flatten();
261
262 assert_eq!(
263 flat["color.primary"].description.as_deref(),
264 Some("Main brand color")
265 );
266 }
267
268 #[test]
269 fn tokens_with_extra_fields() {
270 let json = r##"{
271 "border": {
272 "width": {
273 "$type": "dimension",
274 "sm": {
275 "$value": "1px",
276 "$type": "dimension",
277 "original": { "$value": "1px", "attributes": { "category": "border" } },
278 "attributes": { "category": "border", "type": "width" },
279 "name": "rh-border-width-sm",
280 "path": ["border", "width", "sm"]
281 }
282 }
283 }
284 }"##;
285
286 let tokens = DesignTokens::from_json(json).unwrap();
287 let flat = tokens.flatten();
288
289 assert!(flat.contains_key("border.width.sm"));
290 match &flat["border.width.sm"].value {
291 TokenValue::String(s) => assert_eq!(s, "1px"),
292 _ => panic!("expected string"),
293 }
294 }
295}