numi-core 0.2.0

Core parsing, normalization, rendering, and output orchestration for Numi.
Documentation
use camino::Utf8PathBuf;
use numi_diagnostics::{Diagnostic, Severity};
use numi_ir::{EntryKind, Metadata, RawEntry};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::{Path, PathBuf};

#[derive(Debug)]
pub enum ParseXcassetsError {
    ParseCatalog { source: xcassets::ParseError },
    InvalidCatalogPath { path: PathBuf },
}

impl std::fmt::Display for ParseXcassetsError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ParseCatalog { source } => write!(f, "{source}"),
            Self::InvalidCatalogPath { path } => write!(
                f,
                "asset catalog path {} is not valid UTF-8 and cannot be represented in the IR",
                path.display()
            ),
        }
    }
}

impl std::error::Error for ParseXcassetsError {}

impl From<xcassets::ParseError> for ParseXcassetsError {
    fn from(source: xcassets::ParseError) -> Self {
        Self::ParseCatalog { source }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct XcassetsReport {
    pub entries: Vec<RawEntry>,
    pub warnings: Vec<Diagnostic>,
}

pub fn parse_catalog(catalog_path: &Path) -> Result<XcassetsReport, ParseXcassetsError> {
    let index = xcassets::index_asset_references(catalog_path).map_err(ParseXcassetsError::from)?;
    let mut entries = Vec::new();
    let mut warnings = map_xcassets_diagnostics(&index.diagnostics, catalog_path);

    for reference in &index.references {
        match reference.kind {
            xcassets::AssetReferenceKind::Image | xcassets::AssetReferenceKind::Color => {
                entries.push(entry_from_reference(reference, catalog_path)?);
            }
            xcassets::AssetReferenceKind::AppIcon => warnings.push(unsupported_node_warning(
                catalog_path,
                &reference.relative_path,
                "appiconset",
            )),
        }
    }

    entries.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(XcassetsReport { entries, warnings })
}

fn entry_from_reference(
    reference: &xcassets::AssetReference,
    catalog_root: &Path,
) -> Result<RawEntry, ParseXcassetsError> {
    let (kind, suffix) = match reference.kind {
        xcassets::AssetReferenceKind::Image => (EntryKind::Image, ".imageset"),
        xcassets::AssetReferenceKind::Color => (EntryKind::Color, ".colorset"),
        xcassets::AssetReferenceKind::AppIcon => unreachable!("app icon references are warnings"),
    };
    let asset_name = asset_name_from_relative(&reference.relative_path, suffix);
    let source_path = utf8_path(&catalog_root.join(&reference.relative_path))?;

    Ok(RawEntry {
        path: asset_name.clone(),
        source_path,
        kind,
        properties: asset_properties(&asset_name),
    })
}

fn asset_name_from_relative(relative: &Path, suffix: &str) -> String {
    let mut components = relative
        .iter()
        .map(|component| component.to_string_lossy().into_owned())
        .collect::<Vec<_>>();

    if let Some(last) = components.last_mut()
        && let Some(stripped) = last.strip_suffix(suffix)
    {
        *last = stripped.to_owned();
    }

    components.join("/")
}

fn unsupported_node_warning(catalog_root: &Path, relative_path: &Path, kind: &str) -> Diagnostic {
    Diagnostic {
        severity: Severity::Warning,
        message: format!("unsupported asset node kind `{kind}` was skipped"),
        hint: None,
        job: None,
        path: Some(catalog_root.join(relative_path)),
    }
}

fn map_xcassets_diagnostics(
    diagnostics: &[xcassets::Diagnostic],
    catalog_path: &Path,
) -> Vec<Diagnostic> {
    diagnostics
        .iter()
        .map(|diagnostic| {
            let resolved_path = if diagnostic.path.as_os_str().is_empty() {
                catalog_path.to_path_buf()
            } else if diagnostic.path.is_absolute() {
                diagnostic.path.clone()
            } else {
                catalog_path.join(&diagnostic.path)
            };
            let severity = match diagnostic.severity {
                xcassets::Severity::Warning => Severity::Warning,
                xcassets::Severity::Error => Severity::Error,
            };

            Diagnostic {
                severity,
                message: diagnostic.message.clone(),
                hint: None,
                job: None,
                path: Some(resolved_path),
            }
        })
        .collect()
}

fn utf8_path(path: &Path) -> Result<Utf8PathBuf, ParseXcassetsError> {
    Utf8PathBuf::from_path_buf(path.to_path_buf())
        .map_err(|path| ParseXcassetsError::InvalidCatalogPath { path })
}

fn asset_properties(asset_name: &str) -> Metadata {
    Metadata::from([(
        "assetName".to_string(),
        Value::String(asset_name.to_owned()),
    )])
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{
        fs,
        time::{SystemTime, UNIX_EPOCH},
    };

    fn make_temp_dir(test_name: &str) -> PathBuf {
        let unique = format!(
            "numi-{test_name}-{}-{}",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("clock should be after epoch")
                .as_nanos()
        );
        let path = std::env::temp_dir().join(unique);
        fs::create_dir_all(&path).expect("temp dir should be created");
        path
    }

    #[test]
    fn unsupported_asset_nodes_do_not_emit_supported_entries_without_warning() {
        let temp_dir = make_temp_dir("parse-xcassets-unsupported-asset-node");
        let catalog_dir = temp_dir.join("Assets.xcassets");
        let imageset_dir = catalog_dir.join("Supported.imageset");
        let appiconset_dir = catalog_dir.join("AppIcon.appiconset");

        fs::create_dir_all(&imageset_dir).expect("imageset dir should exist");
        fs::create_dir_all(&appiconset_dir).expect("unsupported appiconset dir should exist");

        fs::write(
            catalog_dir.join("Contents.json"),
            r#"{"info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("catalog contents should be written");

        fs::write(
            imageset_dir.join("Contents.json"),
            r#"{"images": [], "info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("imageset contents should be written");

        fs::write(
            appiconset_dir.join("Contents.json"),
            r#"{"images": [], "info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("unsupported asset contents should be written");

        let report = parse_catalog(&catalog_dir).expect("catalog should parse");
        let warning = report
            .warnings
            .first()
            .expect("unsupported asset node warning should be present");

        assert_eq!(report.entries.len(), 1);
        assert_eq!(report.entries[0].kind, EntryKind::Image);
        assert_eq!(report.warnings.len(), 1);
        assert_eq!(warning.severity, numi_diagnostics::Severity::Warning);
        assert!(warning.message.contains("unsupported asset node kind"));
        let warning_path = warning
            .path
            .as_ref()
            .expect("unsupported node warning should contain a path");
        assert!(warning_path.ends_with("AppIcon.appiconset"));

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn malformed_imageset_still_emits_entry_reference() {
        let temp_dir = make_temp_dir("parse-xcassets-malformed-imageset");
        let catalog_dir = temp_dir.join("Assets.xcassets");
        let valid_imageset_dir = catalog_dir.join("Valid.imageset");
        let broken_imageset_dir = catalog_dir.join("Broken.imageset");

        fs::create_dir_all(&valid_imageset_dir).expect("valid imageset dir should exist");
        fs::create_dir_all(&broken_imageset_dir).expect("broken imageset dir should exist");

        fs::write(
            catalog_dir.join("Contents.json"),
            r#"{"info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("catalog contents should be written");

        fs::write(
            valid_imageset_dir.join("Contents.json"),
            r#"{"images": [], "info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("valid imageset contents should be written");

        fs::write(broken_imageset_dir.join("Contents.json"), r#"{"images": "#)
            .expect("broken imageset contents should be written");

        let report = parse_catalog(&catalog_dir).expect("catalog should parse");

        assert_eq!(report.entries.len(), 2);
        assert!(
            report
                .entries
                .iter()
                .any(|entry| entry.kind == EntryKind::Image && entry.path == "Broken")
        );
        assert!(
            report
                .entries
                .iter()
                .any(|entry| entry.kind == EntryKind::Image && entry.path == "Valid")
        );

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn imageset_without_leaf_contents_json_still_emits_entry() {
        let temp_dir = make_temp_dir("parse-xcassets-missing-leaf-contents");
        let catalog_dir = temp_dir.join("Assets.xcassets");
        let imageset_dir = catalog_dir.join("Loose.imageset");

        fs::create_dir_all(&imageset_dir).expect("imageset dir should exist");

        fs::write(
            catalog_dir.join("Contents.json"),
            r#"{"info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("catalog contents should be written");

        let report = parse_catalog(&catalog_dir).expect("catalog should parse");

        assert!(
            report
                .entries
                .iter()
                .any(|entry| entry.kind == EntryKind::Image && entry.path == "Loose"),
            "imageset without leaf Contents.json should still produce an image entry"
        );

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn unsupported_opaque_folder_is_ignored_without_warning() {
        let temp_dir = make_temp_dir("parse-xcassets-opaque-warning");
        let catalog_dir = temp_dir.join("Assets.xcassets");
        let opaque_dir = catalog_dir.join("Widget.imagestack");

        fs::create_dir_all(&opaque_dir).expect("opaque folder should exist");

        fs::write(
            catalog_dir.join("Contents.json"),
            r#"{"info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("catalog contents should be written");

        fs::write(
            opaque_dir.join("Contents.json"),
            r#"{"info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("opaque folder contents should be written");

        let report = parse_catalog(&catalog_dir).expect("catalog should parse");

        assert!(report.entries.is_empty());
        assert!(report.warnings.is_empty());

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn supported_assets_nested_under_opaque_node_are_discovered() {
        let temp_dir = make_temp_dir("parse-xcassets-opaque-nested-supported");
        let catalog_dir = temp_dir.join("Assets.xcassets");
        let opaque_dir = catalog_dir.join("Atlas.spriteatlas");
        let nested_imageset_dir = opaque_dir.join("Nested.imageset");

        fs::create_dir_all(&nested_imageset_dir).expect("nested imageset dir should exist");

        fs::write(
            catalog_dir.join("Contents.json"),
            r#"{"info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("catalog contents should be written");

        fs::write(
            opaque_dir.join("Contents.json"),
            r#"{"info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("opaque contents should be written");

        fs::write(
            nested_imageset_dir.join("Contents.json"),
            r#"{"images": [], "info": {"author": "xcode", "version": 1}}"#,
        )
        .expect("nested imageset contents should be written");

        let report = parse_catalog(&catalog_dir).expect("catalog should parse");

        assert!(
            report.entries.iter().any(|entry| {
                entry.kind == EntryKind::Image && entry.path == "Atlas.spriteatlas/Nested"
            }),
            "nested supported imageset should produce an entry"
        );

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }
}