1use anyhow::{Context, Result, anyhow};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5const API_BASE_URL: &str = "https://api.iconify.design";
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct IconifyIcon {
10 pub body: String,
11 #[serde(default)]
12 pub width: Option<u32>,
13 #[serde(default)]
14 pub height: Option<u32>,
15 #[serde(default, rename = "viewBox")]
16 pub view_box: Option<String>,
17}
18
19#[derive(Debug, Deserialize)]
21struct IconifyApiResponse {
22 icons: HashMap<String, IconifyIcon>,
24 #[serde(default)]
25 width: Option<u32>,
26 #[serde(default)]
27 height: Option<u32>,
28}
29
30pub struct IconifyClient {
32 client: reqwest::Client,
33 base_url: String,
34}
35
36impl IconifyClient {
37 pub fn new() -> Result<Self> {
39 let client = reqwest::Client::builder()
40 .timeout(std::time::Duration::from_secs(30))
41 .build()
42 .context("Failed to create HTTP client")?;
43
44 Ok(Self {
45 client,
46 base_url: API_BASE_URL.to_string(),
47 })
48 }
49
50 pub async fn fetch_icon(&self, collection: &str, icon_name: &str) -> Result<IconifyIcon> {
52 let url = format!("{}/{}.json?icons={}", self.base_url, collection, icon_name);
53
54 let response = self
55 .client
56 .get(&url)
57 .send()
58 .await
59 .context(format!("Failed to fetch icon {}:{}", collection, icon_name))?;
60
61 if !response.status().is_success() {
62 let status = response.status();
63 let text = response.text().await.unwrap_or_default();
64 return Err(anyhow!(
65 "API request failed with status {}: {}",
66 status,
67 text
68 ));
69 }
70
71 let api_response: IconifyApiResponse = response
72 .json()
73 .await
74 .context("Failed to parse API response")?;
75
76 let icon = api_response
77 .icons
78 .get(icon_name)
79 .ok_or_else(|| {
80 anyhow!(
81 "Icon '{}' not found in collection '{}'",
82 icon_name,
83 collection
84 )
85 })?
86 .clone();
87
88 let width = icon.width.or(api_response.width).unwrap_or(24);
90 let height = icon.height.or(api_response.height).unwrap_or(24);
91
92 let view_box = icon
94 .view_box
95 .clone()
96 .unwrap_or_else(|| format!("0 0 {} {}", width, height));
97
98 Ok(IconifyIcon {
99 body: icon.body,
100 width: Some(width),
101 height: Some(height),
102 view_box: Some(view_box),
103 })
104 }
105
106 }
161
162impl Default for IconifyClient {
163 fn default() -> Self {
164 Self::new().expect("Failed to create Iconify API client")
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use rstest::rstest;
172
173 #[rstest]
174 #[case("mdi", "home")]
175 #[case("heroicons", "arrow-left")]
176 #[case("lucide", "settings")]
177 #[ignore] #[tokio::test]
179 async fn test_fetch_icon(#[case] collection: &str, #[case] icon_name: &str) {
180 let client = IconifyClient::new().unwrap();
181 let icon = client.fetch_icon(collection, icon_name).await.unwrap();
182
183 assert!(!icon.body.is_empty());
184 assert!(icon.width.is_some());
185 assert!(icon.height.is_some());
186 assert!(icon.view_box.is_some());
187 }
188
189 #[tokio::test]
190 #[ignore] async fn test_fetch_nonexistent_icon() {
192 let client = IconifyClient::new().unwrap();
193 let result = client
194 .fetch_icon("mdi", "this-icon-does-not-exist-12345")
195 .await;
196
197 assert!(result.is_err());
198 }
199}