specloom-core 0.2.0

Core contracts and stage execution runtime for the Specloom pipeline.
Documentation
#![forbid(unsafe_code)]

pub const ASSET_MANIFEST_VERSION: &str = "1.0";

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AssetManifest {
    pub manifest_version: String,
    pub generation: GenerationMetadata,
    pub assets: Vec<AssetEntry>,
    pub warnings: Vec<AssetExportWarning>,
}

impl Default for AssetManifest {
    fn default() -> Self {
        Self {
            manifest_version: ASSET_MANIFEST_VERSION.to_string(),
            generation: GenerationMetadata::default(),
            assets: Vec::new(),
            warnings: Vec::new(),
        }
    }
}

pub fn build_asset_manifest(
    normalized: &crate::figma_client::normalizer::NormalizationOutput,
) -> AssetManifest {
    let mut assets = Vec::new();
    let mut warnings = Vec::new();

    for node in &normalized.document.nodes {
        let image_fills = node
            .style
            .fills
            .iter()
            .filter(|fill| fill.kind == crate::figma_client::normalizer::PaintKind::Image)
            .collect::<Vec<_>>();

        for fill in image_fills {
            match fill.image_ref.as_ref() {
                Some(image_ref) => assets.push(AssetEntry {
                    node_id: node.id.clone(),
                    image_ref: Some(image_ref.clone()),
                    hashed_output_filename: format!(
                        "img_{}.png",
                        sanitize_identifier(node.id.as_str())
                    ),
                    format: AssetFormat::Png,
                    width_px: node.bounds.w.max(0.0).round() as u32,
                    height_px: node.bounds.h.max(0.0).round() as u32,
                    dedupe_key: format!("node-{}", sanitize_identifier(node.id.as_str())),
                }),
                None => warnings.push(AssetExportWarning {
                    code: "MISSING_IMAGE_REF".to_string(),
                    message: "Image fill had no image_ref and was skipped.".to_string(),
                    node_id: Some(node.id.clone()),
                    fallback_applied: false,
                }),
            }
        }
    }

    assets.sort_by(|left, right| {
        left.node_id
            .cmp(&right.node_id)
            .then_with(|| left.image_ref.cmp(&right.image_ref))
    });

    AssetManifest {
        manifest_version: ASSET_MANIFEST_VERSION.to_string(),
        generation: GenerationMetadata {
            source_file_key: normalized.document.source.file_key.clone(),
            generator_version: "0.2.0".to_string(),
        },
        assets,
        warnings,
    }
}

fn sanitize_identifier(value: &str) -> String {
    value
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect::<String>()
}

#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct GenerationMetadata {
    pub source_file_key: String,
    pub generator_version: String,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AssetEntry {
    pub node_id: String,
    pub image_ref: Option<String>,
    #[serde(alias = "output_filename")]
    pub hashed_output_filename: String,
    pub format: AssetFormat,
    pub width_px: u32,
    pub height_px: u32,
    pub dedupe_key: String,
}

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssetFormat {
    Png,
    Jpeg,
    Pdf,
    Svg,
}

#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AssetExportWarning {
    pub code: String,
    pub message: String,
    pub node_id: Option<String>,
    pub fallback_applied: bool,
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn asset_manifest_round_trip() {
        let manifest = sample_manifest();
        let json = serde_json::to_string_pretty(&manifest).unwrap();
        let back: AssetManifest = serde_json::from_str(&json).unwrap();
        assert_eq!(manifest, back);
    }

    #[test]
    fn asset_entry_order_is_stable() {
        let manifest = sample_manifest();
        let json = serde_json::to_string_pretty(&manifest).unwrap();
        let back: AssetManifest = serde_json::from_str(&json).unwrap();

        assert_eq!(
            back.assets
                .iter()
                .map(|asset| asset.hashed_output_filename.clone())
                .collect::<Vec<_>>(),
            vec![
                "img_primary_button.png".to_string(),
                "img_logo_mark.pdf".to_string()
            ]
        );
    }

    #[test]
    fn default_manifest_uses_expected_contract_version() {
        let manifest = AssetManifest::default();
        assert_eq!(manifest.manifest_version, ASSET_MANIFEST_VERSION);
        assert!(manifest.generation.source_file_key.is_empty());
        assert!(manifest.generation.generator_version.is_empty());
        assert!(manifest.assets.is_empty());
    }

    #[test]
    fn asset_entry_field_order_is_deterministic() {
        let entry = sample_manifest().assets[0].clone();
        let json = serde_json::to_string(&entry).unwrap();

        assert_eq!(
            json,
            "{\"node_id\":\"10:1\",\"image_ref\":\"figma-image-ref-1\",\"hashed_output_filename\":\"img_primary_button.png\",\"format\":\"png\",\"width_px\":240,\"height_px\":64,\"dedupe_key\":\"hash-aaa111\"}"
        );
    }

    #[test]
    fn manifest_contract_matches_next_stage_map() {
        let manifest = sample_manifest();
        let json = serde_json::to_value(&manifest).unwrap();

        assert_eq!(
            json,
            json!({
                "manifest_version": "1.0",
                "generation": {
                    "source_file_key": "abc123",
                    "generator_version": "0.2.0",
                },
                "assets": [
                    {
                        "node_id": "10:1",
                        "image_ref": "figma-image-ref-1",
                        "hashed_output_filename": "img_primary_button.png",
                        "format": "png",
                        "width_px": 240,
                        "height_px": 64,
                        "dedupe_key": "hash-aaa111",
                    },
                    {
                        "node_id": "12:3",
                        "image_ref": "figma-image-ref-2",
                        "hashed_output_filename": "img_logo_mark.pdf",
                        "format": "pdf",
                        "width_px": 128,
                        "height_px": 128,
                        "dedupe_key": "hash-bbb222",
                    }
                ],
                "warnings": [
                    {
                        "code": "FORMAT_FALLBACK",
                        "message": "SVG export unavailable; fell back to PDF.",
                        "node_id": "12:3",
                        "fallback_applied": true
                    }
                ]
            })
        );
    }

    #[test]
    fn build_asset_manifest_extracts_image_fill_assets_deterministically() {
        let normalized = crate::figma_client::normalizer::NormalizationOutput {
            document: crate::figma_client::normalizer::NormalizedDocument {
                schema_version: crate::figma_client::normalizer::NORMALIZED_SCHEMA_VERSION
                    .to_string(),
                source: crate::figma_client::normalizer::NormalizedSource {
                    file_key: "abc123".to_string(),
                    root_node_id: "1:1".to_string(),
                    figma_api_version: crate::figma_client::normalizer::FIGMA_API_VERSION
                        .to_string(),
                },
                nodes: vec![
                    image_node("10:1", "figma-image-ref-1", 240.0, 64.0),
                    image_node("12:3", "figma-image-ref-2", 128.0, 128.0),
                ],
            },
            warnings: Vec::new(),
        };
        let manifest = super::build_asset_manifest(&normalized);
        assert_eq!(manifest.manifest_version, super::ASSET_MANIFEST_VERSION);
        assert_eq!(manifest.generation.source_file_key, "abc123");
        assert_eq!(manifest.generation.generator_version, "0.2.0");
        assert_eq!(manifest.assets.len(), 2);
        assert_eq!(manifest.assets[0].node_id, "10:1");
        assert_eq!(manifest.assets[1].node_id, "12:3");
        assert_eq!(manifest.assets[0].format, super::AssetFormat::Png);
        assert_eq!(manifest.assets[0].width_px, 240);
        assert_eq!(manifest.assets[0].height_px, 64);
    }

    fn image_node(
        id: &str,
        image_ref: &str,
        width: f32,
        height: f32,
    ) -> crate::figma_client::normalizer::NormalizedNode {
        crate::figma_client::normalizer::NormalizedNode {
            id: id.to_string(),
            parent_id: Some("1:1".to_string()),
            name: "Image".to_string(),
            kind: crate::figma_client::normalizer::NodeKind::Rectangle,
            visible: true,
            bounds: crate::figma_client::normalizer::Bounds {
                x: 0.0,
                y: 0.0,
                w: width,
                h: height,
            },
            layout: None,
            constraints: None,
            style: crate::figma_client::normalizer::NodeStyle {
                opacity: 1.0,
                corner_radius: None,
                fills: vec![crate::figma_client::normalizer::Paint {
                    kind: crate::figma_client::normalizer::PaintKind::Image,
                    color: None,
                    image_ref: Some(image_ref.to_string()),
                }],
                strokes: Vec::new(),
            },
            component: crate::figma_client::normalizer::ComponentMetadata {
                component_id: None,
                component_set_id: None,
                instance_of: None,
                variant_properties: Vec::new(),
            },
            passthrough_fields: std::collections::BTreeMap::new(),
            children: Vec::new(),
        }
    }

    fn sample_manifest() -> AssetManifest {
        AssetManifest {
            manifest_version: ASSET_MANIFEST_VERSION.to_string(),
            generation: GenerationMetadata {
                source_file_key: "abc123".to_string(),
                generator_version: "0.2.0".to_string(),
            },
            assets: vec![
                AssetEntry {
                    node_id: "10:1".to_string(),
                    image_ref: Some("figma-image-ref-1".to_string()),
                    hashed_output_filename: "img_primary_button.png".to_string(),
                    format: AssetFormat::Png,
                    width_px: 240,
                    height_px: 64,
                    dedupe_key: "hash-aaa111".to_string(),
                },
                AssetEntry {
                    node_id: "12:3".to_string(),
                    image_ref: Some("figma-image-ref-2".to_string()),
                    hashed_output_filename: "img_logo_mark.pdf".to_string(),
                    format: AssetFormat::Pdf,
                    width_px: 128,
                    height_px: 128,
                    dedupe_key: "hash-bbb222".to_string(),
                },
            ],
            warnings: vec![AssetExportWarning {
                code: "FORMAT_FALLBACK".to_string(),
                message: "SVG export unavailable; fell back to PDF.".to_string(),
                node_id: Some("12:3".to_string()),
                fallback_applied: true,
            }],
        }
    }
}