Skip to main content

specloom_core/figma_client/
normalizer.rs

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}