dioxus-iconify 0.4.1

CLI tool for importing/vendoring icons from [Iconify](https://icon-sets.iconify.design/) (material, lucid, heroicons,....) or from local SVG files in Dioxus projects
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

const API_BASE_URL: &str = "https://api.iconify.design";

/// Icon data returned from the Iconify API
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IconifyIcon {
    pub body: String,
    #[serde(default)]
    pub width: Option<u32>,
    #[serde(default)]
    pub height: Option<u32>,
    #[serde(default, rename = "viewBox")]
    pub view_box: Option<String>,
}

/// Wrapper for the collection API response
#[derive(Debug, Clone, Serialize, Deserialize)]
struct IconifyCollectionResponse {
    #[serde(default)]
    prefix: Option<String>,
    #[serde(default)]
    total: Option<u32>,
    #[serde(default)]
    title: Option<String>,
    #[serde(default)]
    info: Option<IconifyCollectionInfo>,
}

/// Collection information from Iconify API
/// Based on IconifyInfo: https://iconify.design/docs/types/iconify-info.html
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IconifyCollectionInfo {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub author: Option<IconifyAuthor>,
    #[serde(default)]
    pub license: Option<IconifyLicense>,
    #[serde(default)]
    pub height: Option<u32>,
    #[serde(default)]
    pub category: Option<String>,
    #[serde(default)]
    pub palette: Option<bool>,
    #[serde(default)]
    pub total: Option<u32>,
}

/// Author information in collection metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum IconifyAuthor {
    Simple(String),
    Detailed {
        #[serde(default)]
        name: Option<String>,
        #[serde(default)]
        url: Option<String>,
    },
}

/// License information in collection metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum IconifyLicense {
    Simple(String),
    Detailed {
        #[serde(default)]
        title: Option<String>,
        #[serde(default)]
        spdx: Option<String>,
        #[serde(default)]
        url: Option<String>,
    },
}

/// API response structure for icon data
#[derive(Debug, Deserialize)]
struct IconifyApiResponse {
    // prefix: String,
    icons: HashMap<String, IconifyIcon>,
    #[serde(default)]
    width: Option<u32>,
    #[serde(default)]
    height: Option<u32>,
}

/// Iconify API client
pub struct IconifyClient {
    client: reqwest::Client,
    base_url: String,
}

impl IconifyClient {
    /// Create a new Iconify API client
    pub fn new() -> Result<Self> {
        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .context("Failed to create HTTP client")?;

        Ok(Self {
            client,
            base_url: API_BASE_URL.to_string(),
        })
    }

    /// Fetch collection information from the Iconify API
    pub async fn fetch_collection_info(&self, collection: &str) -> Result<IconifyCollectionInfo> {
        let url = format!(
            "{}/collection?prefix={}&info=true",
            self.base_url, collection
        );

        let response = self.client.get(&url).send().await.context(format!(
            "Failed to fetch collection info for '{}'",
            collection
        ))?;

        if !response.status().is_success() {
            let status = response.status();
            let text = response.text().await.unwrap_or_default();
            return Err(anyhow!(
                "API request failed with status {}: {}",
                status,
                text
            ));
        }

        let response_wrapper: IconifyCollectionResponse = response
            .json()
            .await
            .context("Failed to parse collection info response")?;

        // Extract the info field, or create a basic one from the wrapper
        let collection_info = response_wrapper.info.unwrap_or(IconifyCollectionInfo {
            name: response_wrapper.title,
            author: None,
            license: None,
            height: None,
            category: None,
            palette: None,
            total: response_wrapper.total,
        });

        Ok(collection_info)
    }

    /// Fetch a single icon from the Iconify API
    pub async fn fetch_icon(&self, collection: &str, icon_name: &str) -> Result<IconifyIcon> {
        let url = format!("{}/{}.json?icons={}", self.base_url, collection, icon_name);

        let response = self
            .client
            .get(&url)
            .send()
            .await
            .context(format!("Failed to fetch icon {}:{}", collection, icon_name))?;

        if !response.status().is_success() {
            let status = response.status();
            let text = response.text().await.unwrap_or_default();
            return Err(anyhow!(
                "API request failed with status {}: {}",
                status,
                text
            ));
        }

        let api_response: IconifyApiResponse = response
            .json()
            .await
            .context("Failed to parse API response")?;

        let icon = api_response
            .icons
            .get(icon_name)
            .ok_or_else(|| {
                anyhow!(
                    "Icon '{}' not found in collection '{}'",
                    icon_name,
                    collection
                )
            })?
            .clone();

        // Use icon-specific dimensions or fall back to collection defaults
        let width = icon.width.or(api_response.width).unwrap_or(24);
        let height = icon.height.or(api_response.height).unwrap_or(24);

        // Generate viewBox if not provided
        let view_box = icon
            .view_box
            .clone()
            .unwrap_or_else(|| format!("0 0 {} {}", width, height));

        Ok(IconifyIcon {
            body: icon.body,
            width: Some(width),
            height: Some(height),
            view_box: Some(view_box),
        })
    }

    // /// Fetch multiple icons from the same collection
    // pub fn fetch_icons(
    //     &self,
    //     collection: &str,
    //     icon_names: &[String],
    // ) -> Result<HashMap<String, IconifyIcon>> {
    //     if icon_names.is_empty() {
    //         return Ok(HashMap::new());
    //     }

    //     let icons_param = icon_names.join(",");
    //     let url = format!(
    //         "{}/{}.json?icons={}",
    //         self.base_url, collection, icons_param
    //     );

    //     let response = self.client.get(&url).send().context(format!(
    //         "Failed to fetch icons from collection '{}'",
    //         collection
    //     ))?;

    //     if !response.status().is_success() {
    //         return Err(anyhow!(
    //             "API request failed with status {}: {}",
    //             response.status(),
    //             response.text().unwrap_or_default()
    //         ));
    //     }

    //     let api_response: IconifyApiResponse =
    //         response.json().context("Failed to parse API response")?;

    //     let default_width = api_response.width.unwrap_or(24);
    //     let default_height = api_response.height.unwrap_or(24);

    //     // Process each icon and ensure they have dimensions
    //     let mut result = HashMap::new();
    //     for (name, mut icon) in api_response.icons {
    //         let width = icon.width.unwrap_or(default_width);
    //         let height = icon.height.unwrap_or(default_height);

    //         icon.width = Some(width);
    //         icon.height = Some(height);
    //         icon.view_box = Some(
    //             icon.view_box
    //                 .clone()
    //                 .unwrap_or_else(|| format!("0 0 {} {}", width, height)),
    //         );

    //         result.insert(name, icon);
    //     }

    //     Ok(result)
    // }
}

impl Default for IconifyClient {
    fn default() -> Self {
        Self::new().expect("Failed to create Iconify API client")
    }
}

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

    #[rstest]
    #[case("mdi", "home")]
    #[case("heroicons", "arrow-left")]
    #[case("lucide", "settings")]
    #[ignore] // Requires internet connection
    #[tokio::test]
    async fn test_fetch_icon(#[case] collection: &str, #[case] icon_name: &str) {
        let client = IconifyClient::new().unwrap();
        let icon = client.fetch_icon(collection, icon_name).await.unwrap();

        assert!(!icon.body.is_empty());
        assert!(icon.width.is_some());
        assert!(icon.height.is_some());
        assert!(icon.view_box.is_some());
    }

    #[tokio::test]
    #[ignore] // Requires internet connection
    async fn test_fetch_nonexistent_icon() {
        let client = IconifyClient::new().unwrap();
        let result = client
            .fetch_icon("mdi", "this-icon-does-not-exist-12345")
            .await;

        assert!(result.is_err());
    }

    #[rstest]
    #[case("mdi")]
    #[case("heroicons")]
    #[case("lucide")]
    #[ignore] // Requires internet connection
    #[tokio::test]
    async fn test_fetch_collection_info(#[case] collection: &str) {
        let client = IconifyClient::new().unwrap();
        let info = client.fetch_collection_info(collection).await.unwrap();

        // The API should return collection info with at least a name
        assert!(info.name.is_some(), "Collection should have a name");
    }
}