1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value};
4
5pub const NORMALIZED_SCHEMA_VERSION: &str = "1.0";
6pub const FIGMA_API_VERSION: &str = "v1";
7
8#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
9pub enum NormalizationError {
10 #[error("missing required payload field: {0}")]
11 MissingRequiredPayloadField(String),
12 #[error("invalid payload field: {0}")]
13 InvalidPayloadField(String),
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct NormalizationWarning {
18 pub code: String,
19 pub message: String,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub node_id: Option<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
25pub struct NormalizationOutput {
26 pub document: NormalizedDocument,
27 #[serde(default)]
28 #[serde(skip_serializing_if = "Vec::is_empty")]
29 pub warnings: Vec<NormalizationWarning>,
30}
31
32pub fn normalize_snapshot(
33 snapshot: &super::RawFigmaSnapshot,
34) -> Result<NormalizationOutput, NormalizationError> {
35 let payload = snapshot.payload.as_object().ok_or_else(|| {
36 NormalizationError::InvalidPayloadField("payload must be a JSON object".to_string())
37 })?;
38 let root = payload
39 .get("document")
40 .ok_or_else(|| NormalizationError::MissingRequiredPayloadField("document".to_string()))?;
41
42 let mut nodes = Vec::new();
43 let mut warnings = Vec::new();
44 let root_node_id = normalize_node(root, None, &mut nodes, &mut warnings)?;
45
46 let document = NormalizedDocument {
47 schema_version: NORMALIZED_SCHEMA_VERSION.to_string(),
48 source: NormalizedSource {
49 file_key: snapshot.source.file_key.clone(),
50 root_node_id,
51 figma_api_version: snapshot.source.figma_api_version.clone(),
52 },
53 nodes,
54 };
55
56 Ok(NormalizationOutput { document, warnings })
57}
58
59fn normalize_node(
60 node_value: &Value,
61 parent_id: Option<&str>,
62 nodes: &mut Vec<NormalizedNode>,
63 warnings: &mut Vec<NormalizationWarning>,
64) -> Result<String, NormalizationError> {
65 let node = node_value.as_object().ok_or_else(|| {
66 NormalizationError::InvalidPayloadField("node must be a JSON object".to_string())
67 })?;
68
69 let id = required_string(node, "id")?;
70 let name = node
71 .get("name")
72 .and_then(Value::as_str)
73 .unwrap_or_default()
74 .to_string();
75 let kind = map_node_kind(
76 node.get("type").and_then(Value::as_str),
77 id.as_str(),
78 warnings,
79 );
80 let visible = node.get("visible").and_then(Value::as_bool).unwrap_or(true);
81 let bounds = parse_bounds(node.get("absoluteBoundingBox"))?;
82 let style = parse_style(node)?;
83 let passthrough_fields = collect_passthrough_fields(node);
84
85 let node_index = nodes.len();
86 nodes.push(NormalizedNode {
87 id: id.clone(),
88 parent_id: parent_id.map(str::to_string),
89 name,
90 kind,
91 visible,
92 bounds,
93 layout: None,
94 constraints: None,
95 style,
96 component: default_component(),
97 passthrough_fields,
98 children: Vec::new(),
99 });
100
101 let children = parse_children(node.get("children"))?;
102 let mut child_ids = Vec::new();
103 for child in children {
104 let child_id = normalize_node(child, Some(id.as_str()), nodes, warnings)?;
105 child_ids.push(child_id);
106 }
107 nodes[node_index].children = child_ids;
108
109 Ok(id)
110}
111
112fn required_string(
113 node: &serde_json::Map<String, Value>,
114 field: &'static str,
115) -> Result<String, NormalizationError> {
116 node.get(field)
117 .and_then(Value::as_str)
118 .map(str::to_string)
119 .ok_or_else(|| {
120 NormalizationError::InvalidPayloadField(format!("node.{field} must be a string"))
121 })
122}
123
124fn map_node_kind(
125 node_type: Option<&str>,
126 node_id: &str,
127 warnings: &mut Vec<NormalizationWarning>,
128) -> NodeKind {
129 match node_type.unwrap_or("UNKNOWN") {
130 "FRAME" => NodeKind::Frame,
131 "GROUP" => NodeKind::Group,
132 "COMPONENT" => NodeKind::Component,
133 "INSTANCE" => NodeKind::Instance,
134 "COMPONENT_SET" => NodeKind::ComponentSet,
135 "TEXT" => NodeKind::Text,
136 "RECTANGLE" => NodeKind::Rectangle,
137 "ELLIPSE" => NodeKind::Ellipse,
138 "STAR" => NodeKind::Star,
139 "VECTOR" => NodeKind::Vector,
140 other => {
141 warnings.push(NormalizationWarning {
142 code: "UNSUPPORTED_NODE_TYPE".to_string(),
143 message: format!("unsupported node type `{other}` normalized as `unknown`"),
144 node_id: Some(node_id.to_string()),
145 });
146 NodeKind::Unknown
147 }
148 }
149}
150
151fn parse_bounds(bounds_value: Option<&Value>) -> Result<Bounds, NormalizationError> {
152 let Some(bounds) = bounds_value else {
153 return Ok(Bounds {
154 x: 0.0,
155 y: 0.0,
156 w: 0.0,
157 h: 0.0,
158 });
159 };
160
161 let object = bounds.as_object().ok_or_else(|| {
162 NormalizationError::InvalidPayloadField(
163 "node.absoluteBoundingBox must be a JSON object".to_string(),
164 )
165 })?;
166
167 Ok(Bounds {
168 x: required_f32(object, "x", "node.absoluteBoundingBox.x")?,
169 y: required_f32(object, "y", "node.absoluteBoundingBox.y")?,
170 w: required_f32(object, "width", "node.absoluteBoundingBox.width")?,
171 h: required_f32(object, "height", "node.absoluteBoundingBox.height")?,
172 })
173}
174
175fn parse_style(node: &Map<String, Value>) -> Result<NodeStyle, NormalizationError> {
176 let mut style = default_style();
177
178 let Some(fills) = node.get("fills") else {
179 return Ok(style);
180 };
181
182 style.fills = parse_fills(fills)?;
183
184 Ok(style)
185}
186
187fn parse_fills(fills_value: &Value) -> Result<Vec<Paint>, NormalizationError> {
188 let fills = fills_value.as_array().ok_or_else(|| {
189 NormalizationError::InvalidPayloadField("node.style.fills must be an array".to_string())
190 })?;
191
192 fills
193 .iter()
194 .map(parse_fill)
195 .collect::<Result<Vec<_>, NormalizationError>>()
196}
197
198fn parse_fill(fill_value: &Value) -> Result<Paint, NormalizationError> {
199 let fill = fill_value.as_object().ok_or_else(|| {
200 NormalizationError::InvalidPayloadField("node.style.fills[] must be an object".to_string())
201 })?;
202
203 let kind = fill
204 .get("type")
205 .and_then(Value::as_str)
206 .ok_or_else(|| {
207 NormalizationError::InvalidPayloadField(
208 "node.style.fills[].type must be a string".to_string(),
209 )
210 })
211 .and_then(parse_paint_kind)?;
212
213 let color = match kind {
214 PaintKind::Solid => fill
215 .get("color")
216 .map(parse_color)
217 .transpose()?
218 .map(|mut color| {
219 if let Some(opacity) = fill.get("opacity").and_then(Value::as_f64) {
220 color.a *= opacity as f32;
221 }
222 color
223 }),
224 _ => None,
225 };
226
227 let image_ref = match kind {
228 PaintKind::Image => fill
229 .get("imageRef")
230 .and_then(Value::as_str)
231 .map(str::to_string),
232 _ => None,
233 };
234
235 Ok(Paint {
236 kind,
237 color,
238 image_ref,
239 })
240}
241
242fn parse_paint_kind(kind: &str) -> Result<PaintKind, NormalizationError> {
243 match kind {
244 "SOLID" => Ok(PaintKind::Solid),
245 "IMAGE" => Ok(PaintKind::Image),
246 "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" => {
247 Ok(PaintKind::Gradient)
248 }
249 _ => Err(NormalizationError::InvalidPayloadField(format!(
250 "unsupported node.style.fills[].type: {kind}"
251 ))),
252 }
253}
254
255fn parse_color(color_value: &Value) -> Result<Color, NormalizationError> {
256 let color = color_value.as_object().ok_or_else(|| {
257 NormalizationError::InvalidPayloadField(
258 "node.style.fills[].color must be an object".to_string(),
259 )
260 })?;
261
262 Ok(Color {
263 r: required_f32(color, "r", "node.style.fills[].color.r")?,
264 g: required_f32(color, "g", "node.style.fills[].color.g")?,
265 b: required_f32(color, "b", "node.style.fills[].color.b")?,
266 a: required_f32(color, "a", "node.style.fills[].color.a")?,
267 })
268}
269
270fn required_f32(
271 object: &serde_json::Map<String, Value>,
272 field: &'static str,
273 description: &'static str,
274) -> Result<f32, NormalizationError> {
275 object
276 .get(field)
277 .and_then(Value::as_f64)
278 .map(|number| number as f32)
279 .ok_or_else(|| {
280 NormalizationError::InvalidPayloadField(format!("{description} must be a number"))
281 })
282}
283
284fn parse_children(children_value: Option<&Value>) -> Result<Vec<&Value>, NormalizationError> {
285 let Some(value) = children_value else {
286 return Ok(Vec::new());
287 };
288 value
289 .as_array()
290 .map(|children| children.iter().collect::<Vec<_>>())
291 .ok_or_else(|| {
292 NormalizationError::InvalidPayloadField("node.children must be an array".to_string())
293 })
294}
295
296fn collect_passthrough_fields(node: &serde_json::Map<String, Value>) -> BTreeMap<String, Value> {
297 const SUPPORTED_FIELDS: [&str; 6] = [
298 "id",
299 "name",
300 "type",
301 "visible",
302 "absoluteBoundingBox",
303 "children",
304 ];
305
306 node.iter()
307 .filter(|(field, _)| !SUPPORTED_FIELDS.contains(&field.as_str()))
308 .filter_map(|(field, value)| {
309 prune_passthrough_value(value).map(|pruned| (field.clone(), pruned))
310 })
311 .collect()
312}
313
314fn prune_passthrough_value(value: &Value) -> Option<Value> {
315 match value {
316 Value::Null => None,
317 Value::Array(values) => {
318 let pruned = values
319 .iter()
320 .filter_map(prune_passthrough_value)
321 .collect::<Vec<_>>();
322 if pruned.is_empty() {
323 None
324 } else {
325 Some(Value::Array(pruned))
326 }
327 }
328 Value::Object(map) => {
329 let pruned = map
330 .iter()
331 .filter_map(|(key, value)| {
332 prune_passthrough_value(value).map(|pruned| (key.clone(), pruned))
333 })
334 .collect::<serde_json::Map<_, _>>();
335 if pruned.is_empty() {
336 None
337 } else {
338 Some(Value::Object(pruned))
339 }
340 }
341 _ => Some(value.clone()),
342 }
343}
344
345fn default_style() -> NodeStyle {
346 NodeStyle {
347 opacity: 1.0,
348 corner_radius: None,
349 fills: Vec::new(),
350 strokes: Vec::new(),
351 }
352}
353
354fn default_component() -> ComponentMetadata {
355 ComponentMetadata {
356 component_id: None,
357 component_set_id: None,
358 instance_of: None,
359 variant_properties: Vec::new(),
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
364pub struct NormalizedDocument {
365 pub schema_version: String,
366 pub source: NormalizedSource,
367 pub nodes: Vec<NormalizedNode>,
368}
369
370impl Default for NormalizedDocument {
371 fn default() -> Self {
372 Self {
373 schema_version: NORMALIZED_SCHEMA_VERSION.to_string(),
374 source: NormalizedSource::default(),
375 nodes: Vec::new(),
376 }
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
381pub struct NormalizedSource {
382 pub file_key: String,
383 pub root_node_id: String,
384 pub figma_api_version: String,
385}
386
387impl Default for NormalizedSource {
388 fn default() -> Self {
389 Self {
390 file_key: String::new(),
391 root_node_id: String::new(),
392 figma_api_version: FIGMA_API_VERSION.to_string(),
393 }
394 }
395}
396
397#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
398pub struct NormalizedNode {
399 pub id: String,
400 #[serde(skip_serializing_if = "Option::is_none")]
401 pub parent_id: Option<String>,
402 pub name: String,
403 pub kind: NodeKind,
404 pub visible: bool,
405 pub bounds: Bounds,
406 #[serde(skip_serializing_if = "Option::is_none")]
407 pub layout: Option<LayoutMetadata>,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 pub constraints: Option<LayoutConstraints>,
410 pub style: NodeStyle,
411 pub component: ComponentMetadata,
412 #[serde(default)]
413 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
414 pub passthrough_fields: BTreeMap<String, Value>,
415 #[serde(default)]
416 #[serde(skip_serializing_if = "Vec::is_empty")]
417 pub children: Vec<String>,
418}
419
420#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
421#[serde(rename_all = "snake_case")]
422pub enum NodeKind {
423 Frame,
424 Group,
425 Component,
426 Instance,
427 ComponentSet,
428 Text,
429 Rectangle,
430 Ellipse,
431 Star,
432 Vector,
433 Unknown,
434}
435
436#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
437pub struct Bounds {
438 pub x: f32,
439 pub y: f32,
440 pub w: f32,
441 pub h: f32,
442}
443
444#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
445pub struct LayoutMetadata {
446 pub mode: LayoutMode,
447 pub primary_align: Align,
448 pub cross_align: Align,
449 pub item_spacing: f32,
450 pub padding: Padding,
451}
452
453#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum LayoutMode {
456 None,
457 Horizontal,
458 Vertical,
459}
460
461#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
462#[serde(rename_all = "snake_case")]
463pub enum Align {
464 Start,
465 Center,
466 End,
467 Stretch,
468}
469
470#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
471pub struct Padding {
472 pub top: f32,
473 pub right: f32,
474 pub bottom: f32,
475 pub left: f32,
476}
477
478#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
479pub struct LayoutConstraints {
480 pub horizontal: ConstraintMode,
481 pub vertical: ConstraintMode,
482}
483
484#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
485#[serde(rename_all = "snake_case")]
486pub enum ConstraintMode {
487 Min,
488 Max,
489 Stretch,
490 Center,
491 Scale,
492}
493
494#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
495pub struct NodeStyle {
496 pub opacity: f32,
497 #[serde(skip_serializing_if = "Option::is_none")]
498 pub corner_radius: Option<f32>,
499 #[serde(default)]
500 #[serde(skip_serializing_if = "Vec::is_empty")]
501 pub fills: Vec<Paint>,
502 #[serde(default)]
503 #[serde(skip_serializing_if = "Vec::is_empty")]
504 pub strokes: Vec<Stroke>,
505}
506
507#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
508pub struct Paint {
509 pub kind: PaintKind,
510 #[serde(skip_serializing_if = "Option::is_none")]
511 pub color: Option<Color>,
512 #[serde(skip_serializing_if = "Option::is_none")]
513 pub image_ref: Option<String>,
514}
515
516#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
517#[serde(rename_all = "snake_case")]
518pub enum PaintKind {
519 Solid,
520 Image,
521 Gradient,
522}
523
524#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
525pub struct Color {
526 pub r: f32,
527 pub g: f32,
528 pub b: f32,
529 pub a: f32,
530}
531
532#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
533pub struct Stroke {
534 pub width: f32,
535 pub color: Color,
536}
537
538#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
539pub struct ComponentMetadata {
540 #[serde(skip_serializing_if = "Option::is_none")]
541 pub component_id: Option<String>,
542 #[serde(skip_serializing_if = "Option::is_none")]
543 pub component_set_id: Option<String>,
544 #[serde(skip_serializing_if = "Option::is_none")]
545 pub instance_of: Option<String>,
546 #[serde(default)]
547 #[serde(skip_serializing_if = "Vec::is_empty")]
548 pub variant_properties: Vec<VariantProperty>,
549}
550
551#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
552pub struct VariantProperty {
553 pub name: String,
554 pub value: String,
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use serde_json::Value;
561
562 #[test]
563 fn normalized_document_round_trip() {
564 let doc = sample_document();
565 let json = serde_json::to_string_pretty(&doc).unwrap();
566 let back: NormalizedDocument = serde_json::from_str(&json).unwrap();
567 assert_eq!(doc, back);
568 }
569
570 #[test]
571 fn children_order_is_stable() {
572 let doc = sample_document();
573 let json = serde_json::to_string_pretty(&doc).unwrap();
574 let back: NormalizedDocument = serde_json::from_str(&json).unwrap();
575 assert_eq!(
576 back.nodes[0].children,
577 vec!["2:1".to_string(), "3:1".to_string()]
578 );
579 }
580
581 #[test]
582 fn node_collection_order_is_stable() {
583 let doc = sample_document();
584 let json = serde_json::to_string_pretty(&doc).unwrap();
585 let back: NormalizedDocument = serde_json::from_str(&json).unwrap();
586 assert_eq!(
587 back.nodes
588 .iter()
589 .map(|node| node.id.clone())
590 .collect::<Vec<_>>(),
591 vec!["1:1".to_string(), "2:1".to_string(), "3:1".to_string()]
592 );
593 }
594
595 #[test]
596 fn root_contract_fields_are_explicit() {
597 let doc = sample_document();
598 let json = serde_json::to_value(&doc).unwrap();
599
600 let object = json
601 .as_object()
602 .expect("normalized document should serialize as an object");
603 assert_eq!(
604 object.get("schema_version"),
605 Some(&Value::String(NORMALIZED_SCHEMA_VERSION.to_string()))
606 );
607 assert!(object.contains_key("source"));
608 assert!(object.contains_key("nodes"));
609
610 let source = object
611 .get("source")
612 .and_then(Value::as_object)
613 .expect("source should serialize as an object");
614 assert_eq!(
615 source.get("file_key"),
616 Some(&Value::String("abc123".to_string()))
617 );
618 assert_eq!(
619 source.get("root_node_id"),
620 Some(&Value::String("1:1".to_string()))
621 );
622 assert_eq!(
623 source.get("figma_api_version"),
624 Some(&Value::String(FIGMA_API_VERSION.to_string()))
625 );
626 }
627
628 #[test]
629 fn defaults_include_explicit_versions() {
630 let doc = NormalizedDocument::default();
631 assert_eq!(doc.schema_version, NORMALIZED_SCHEMA_VERSION);
632 assert_eq!(doc.source.figma_api_version, FIGMA_API_VERSION);
633 }
634
635 #[test]
636 fn normalized_node_deserializes_when_empty_collections_are_omitted() {
637 let node: NormalizedNode = serde_json::from_str(
638 r#"{
639 "id": "1:1",
640 "name": "Root",
641 "kind": "frame",
642 "visible": true,
643 "bounds": { "x": 0.0, "y": 0.0, "w": 100.0, "h": 100.0 },
644 "style": { "opacity": 1.0 },
645 "component": {}
646 }"#,
647 )
648 .expect("node without empty collection fields should deserialize");
649
650 assert!(node.children.is_empty());
651 assert!(node.passthrough_fields.is_empty());
652 assert!(node.style.fills.is_empty());
653 assert!(node.style.strokes.is_empty());
654 assert!(node.component.variant_properties.is_empty());
655 }
656
657 #[test]
658 fn normalize_snapshot_maps_minimal_document_tree() {
659 let request =
660 crate::figma_client::FetchNodesRequest::new("abc123".to_string(), "1:1".to_string())
661 .expect("request should be valid");
662 let snapshot = crate::figma_client::fetch_snapshot_from_fixture(
663 &request,
664 r#"{
665 "document": {
666 "id": "1:1",
667 "name": "Root",
668 "type": "FRAME",
669 "visible": true,
670 "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 390.0, "height": 844.0 },
671 "children": [
672 {
673 "id": "2:1",
674 "name": "Title",
675 "type": "TEXT",
676 "visible": true,
677 "absoluteBoundingBox": { "x": 20.0, "y": 24.0, "width": 140.0, "height": 40.0 },
678 "children": []
679 }
680 ]
681 }
682 }"#,
683 )
684 .expect("fixture should parse");
685
686 let output = super::normalize_snapshot(&snapshot).expect("snapshot should normalize");
687 assert!(output.warnings.is_empty());
688 assert_eq!(output.document.source.file_key, "abc123");
689 assert_eq!(output.document.source.root_node_id, "1:1");
690 assert_eq!(output.document.nodes.len(), 2);
691 assert_eq!(
692 output
693 .document
694 .nodes
695 .iter()
696 .map(|node| node.id.as_str())
697 .collect::<Vec<_>>(),
698 vec!["1:1", "2:1"]
699 );
700 assert_eq!(output.document.nodes[0].children, vec!["2:1".to_string()]);
701 assert_eq!(output.document.nodes[1].children, Vec::<String>::new());
702 assert!(output.document.nodes[0].passthrough_fields.is_empty());
703 assert!(output.document.nodes[1].passthrough_fields.is_empty());
704 }
705
706 #[test]
707 fn normalize_snapshot_traverses_instance_children() {
708 let request =
709 crate::figma_client::FetchNodesRequest::new("abc123".to_string(), "1:1".to_string())
710 .expect("request should be valid");
711 let snapshot = crate::figma_client::fetch_snapshot_from_fixture(
712 &request,
713 r#"{
714 "document": {
715 "id": "1:1",
716 "name": "Root",
717 "type": "FRAME",
718 "visible": true,
719 "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 390.0, "height": 844.0 },
720 "children": [
721 {
722 "id": "2:1",
723 "name": "Button Instance",
724 "type": "INSTANCE",
725 "visible": true,
726 "absoluteBoundingBox": { "x": 20.0, "y": 24.0, "width": 140.0, "height": 40.0 },
727 "children": [
728 {
729 "id": "3:1",
730 "name": "Label",
731 "type": "TEXT",
732 "visible": true,
733 "absoluteBoundingBox": { "x": 24.0, "y": 30.0, "width": 80.0, "height": 20.0 },
734 "children": []
735 }
736 ]
737 }
738 ]
739 }
740 }"#,
741 )
742 .expect("fixture should parse");
743
744 let output = super::normalize_snapshot(&snapshot).expect("snapshot should normalize");
745 assert!(output.warnings.is_empty());
746 assert_eq!(
747 output
748 .document
749 .nodes
750 .iter()
751 .map(|node| node.id.as_str())
752 .collect::<Vec<_>>(),
753 vec!["1:1", "2:1", "3:1"]
754 );
755 assert_eq!(output.document.nodes[0].children, vec!["2:1".to_string()]);
756 assert_eq!(output.document.nodes[1].kind, NodeKind::Instance);
757 assert_eq!(output.document.nodes[1].children, vec!["3:1".to_string()]);
758 assert_eq!(output.document.nodes[2].parent_id, Some("2:1".to_string()));
759 }
760
761 #[test]
762 fn normalize_snapshot_preserves_unsupported_fields_in_passthrough() {
763 let request =
764 crate::figma_client::FetchNodesRequest::new("abc123".to_string(), "1:1".to_string())
765 .expect("request should be valid");
766 let snapshot = crate::figma_client::fetch_snapshot_from_fixture(
767 &request,
768 r#"{
769 "document": {
770 "id": "1:1",
771 "name": "Root",
772 "type": "FRAME",
773 "visible": true,
774 "blendMode": "MULTIPLY",
775 "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 390.0, "height": 844.0 },
776 "children": []
777 }
778 }"#,
779 )
780 .expect("fixture should parse");
781
782 let output = super::normalize_snapshot(&snapshot).expect("snapshot should normalize");
783 assert!(output.warnings.is_empty());
784 assert_eq!(
785 output.document.nodes[0].passthrough_fields.get("blendMode"),
786 Some(&Value::String("MULTIPLY".to_string()))
787 );
788 }
789
790 #[test]
791 fn normalize_snapshot_prunes_null_and_empty_passthrough_values() {
792 let request =
793 crate::figma_client::FetchNodesRequest::new("abc123".to_string(), "1:1".to_string())
794 .expect("request should be valid");
795 let snapshot = crate::figma_client::fetch_snapshot_from_fixture(
796 &request,
797 r#"{
798 "document": {
799 "id": "1:1",
800 "name": "Root",
801 "type": "FRAME",
802 "visible": true,
803 "absoluteRenderBounds": null,
804 "effects": [],
805 "interactions": [],
806 "styles": {
807 "fill": null,
808 "stroke": [],
809 "text": "body"
810 },
811 "boundVariables": {
812 "width": null,
813 "height": "token/height"
814 },
815 "absoluteBoundingBox": { "x": 0.0, "y": 0.0, "width": 390.0, "height": 844.0 },
816 "children": []
817 }
818 }"#,
819 )
820 .expect("fixture should parse");
821
822 let output = super::normalize_snapshot(&snapshot).expect("snapshot should normalize");
823 let passthrough = &output.document.nodes[0].passthrough_fields;
824
825 assert!(!passthrough.contains_key("absoluteRenderBounds"));
826 assert!(!passthrough.contains_key("effects"));
827 assert!(!passthrough.contains_key("interactions"));
828 assert_eq!(
829 passthrough.get("styles"),
830 Some(&serde_json::json!({ "text": "body" }))
831 );
832 assert_eq!(
833 passthrough.get("boundVariables"),
834 Some(&serde_json::json!({ "height": "token/height" }))
835 );
836 }
837
838 #[test]
839 fn normalize_snapshot_rejects_missing_document_payload() {
840 let request =
841 crate::figma_client::FetchNodesRequest::new("abc123".to_string(), "1:1".to_string())
842 .expect("request should be valid");
843 let snapshot =
844 crate::figma_client::fetch_snapshot_from_fixture(&request, r#"{"components":{}}"#)
845 .expect("fixture should parse");
846
847 let err = super::normalize_snapshot(&snapshot).expect_err("missing document should fail");
848 assert!(
849 err.to_string()
850 .contains("missing required payload field: document")
851 );
852 }
853
854 #[test]
855 fn parse_style_maps_fills() {
856 let style = parse_style(
857 serde_json::json!({
858 "fills": [
859 {
860 "type": "SOLID",
861 "color": { "r": 0.2, "g": 0.4, "b": 0.6, "a": 0.8 },
862 "opacity": 0.5
863 },
864 {
865 "type": "IMAGE",
866 "imageRef": "img-ref-1"
867 },
868 {
869 "type": "GRADIENT_LINEAR"
870 }
871 ]
872 })
873 .as_object()
874 .unwrap(),
875 )
876 .expect("style should parse");
877
878 assert_eq!(
879 style.fills,
880 vec![
881 Paint {
882 kind: PaintKind::Solid,
883 color: Some(Color {
884 r: 0.2,
885 g: 0.4,
886 b: 0.6,
887 a: 0.4,
888 }),
889 image_ref: None,
890 },
891 Paint {
892 kind: PaintKind::Image,
893 color: None,
894 image_ref: Some("img-ref-1".to_string()),
895 },
896 Paint {
897 kind: PaintKind::Gradient,
898 color: None,
899 image_ref: None,
900 },
901 ]
902 );
903 }
904
905 #[test]
906 fn parse_style_rejects_non_array_fills() {
907 let err = parse_style(
908 serde_json::json!({
909 "fills": { "type": "SOLID" }
910 })
911 .as_object()
912 .unwrap(),
913 )
914 .expect_err("fills object should be rejected");
915
916 assert!(
917 err.to_string()
918 .contains("node.style.fills must be an array")
919 );
920 }
921
922 #[test]
923 fn parse_style_rejects_unsupported_fill_type() {
924 let err = parse_style(
925 serde_json::json!({
926 "fills": [
927 { "type": "EMOJI" }
928 ]
929 })
930 .as_object()
931 .unwrap(),
932 )
933 .expect_err("unsupported paint type should be rejected");
934
935 assert!(
936 err.to_string()
937 .contains("unsupported node.style.fills[].type: EMOJI")
938 );
939 }
940
941 fn sample_document() -> NormalizedDocument {
942 NormalizedDocument {
943 schema_version: NORMALIZED_SCHEMA_VERSION.to_string(),
944 source: NormalizedSource {
945 file_key: "abc123".to_string(),
946 root_node_id: "1:1".to_string(),
947 figma_api_version: FIGMA_API_VERSION.to_string(),
948 },
949 nodes: vec![
950 NormalizedNode {
951 id: "1:1".to_string(),
952 parent_id: None,
953 name: "Root".to_string(),
954 kind: NodeKind::Frame,
955 visible: true,
956 bounds: Bounds {
957 x: 0.0,
958 y: 0.0,
959 w: 390.0,
960 h: 844.0,
961 },
962 layout: Some(LayoutMetadata {
963 mode: LayoutMode::Vertical,
964 primary_align: Align::Start,
965 cross_align: Align::Stretch,
966 item_spacing: 16.0,
967 padding: Padding {
968 top: 24.0,
969 right: 20.0,
970 bottom: 24.0,
971 left: 20.0,
972 },
973 }),
974 constraints: Some(LayoutConstraints {
975 horizontal: ConstraintMode::Stretch,
976 vertical: ConstraintMode::Min,
977 }),
978 style: NodeStyle {
979 opacity: 1.0,
980 corner_radius: Some(12.0),
981 fills: vec![Paint {
982 kind: PaintKind::Solid,
983 color: Some(Color {
984 r: 1.0,
985 g: 1.0,
986 b: 1.0,
987 a: 1.0,
988 }),
989 image_ref: None,
990 }],
991 strokes: vec![Stroke {
992 width: 1.0,
993 color: Color {
994 r: 0.9,
995 g: 0.9,
996 b: 0.9,
997 a: 1.0,
998 },
999 }],
1000 },
1001 component: ComponentMetadata {
1002 component_id: None,
1003 component_set_id: None,
1004 instance_of: None,
1005 variant_properties: vec![VariantProperty {
1006 name: "state".to_string(),
1007 value: "default".to_string(),
1008 }],
1009 },
1010 passthrough_fields: BTreeMap::new(),
1011 children: vec!["2:1".to_string(), "3:1".to_string()],
1012 },
1013 NormalizedNode {
1014 id: "2:1".to_string(),
1015 parent_id: Some("1:1".to_string()),
1016 name: "Title".to_string(),
1017 kind: NodeKind::Text,
1018 visible: true,
1019 bounds: Bounds {
1020 x: 20.0,
1021 y: 24.0,
1022 w: 160.0,
1023 h: 38.0,
1024 },
1025 layout: None,
1026 constraints: Some(LayoutConstraints {
1027 horizontal: ConstraintMode::Stretch,
1028 vertical: ConstraintMode::Min,
1029 }),
1030 style: NodeStyle {
1031 opacity: 1.0,
1032 corner_radius: None,
1033 fills: vec![Paint {
1034 kind: PaintKind::Solid,
1035 color: Some(Color {
1036 r: 0.1,
1037 g: 0.1,
1038 b: 0.1,
1039 a: 1.0,
1040 }),
1041 image_ref: None,
1042 }],
1043 strokes: Vec::new(),
1044 },
1045 component: ComponentMetadata {
1046 component_id: None,
1047 component_set_id: None,
1048 instance_of: None,
1049 variant_properties: Vec::new(),
1050 },
1051 passthrough_fields: BTreeMap::new(),
1052 children: Vec::new(),
1053 },
1054 NormalizedNode {
1055 id: "3:1".to_string(),
1056 parent_id: Some("1:1".to_string()),
1057 name: "PrimaryButton".to_string(),
1058 kind: NodeKind::Instance,
1059 visible: true,
1060 bounds: Bounds {
1061 x: 20.0,
1062 y: 78.0,
1063 w: 350.0,
1064 h: 48.0,
1065 },
1066 layout: None,
1067 constraints: Some(LayoutConstraints {
1068 horizontal: ConstraintMode::Stretch,
1069 vertical: ConstraintMode::Min,
1070 }),
1071 style: NodeStyle {
1072 opacity: 1.0,
1073 corner_radius: Some(8.0),
1074 fills: vec![Paint {
1075 kind: PaintKind::Solid,
1076 color: Some(Color {
1077 r: 0.14,
1078 g: 0.45,
1079 b: 0.95,
1080 a: 1.0,
1081 }),
1082 image_ref: None,
1083 }],
1084 strokes: Vec::new(),
1085 },
1086 component: ComponentMetadata {
1087 component_id: Some("42:7".to_string()),
1088 component_set_id: Some("42:0".to_string()),
1089 instance_of: Some("42:7".to_string()),
1090 variant_properties: vec![VariantProperty {
1091 name: "state".to_string(),
1092 value: "enabled".to_string(),
1093 }],
1094 },
1095 passthrough_fields: BTreeMap::new(),
1096 children: Vec::new(),
1097 },
1098 ],
1099 }
1100 }
1101}