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, Clone, Serialize, Deserialize)]
22pub struct IconifyCollectionInfo {
23 #[serde(default)]
24 pub name: Option<String>,
25 #[serde(default)]
26 pub author: Option<IconifyAuthor>,
27 #[serde(default)]
28 pub license: Option<IconifyLicense>,
29 #[serde(default)]
30 pub height: Option<u32>,
31 #[serde(default)]
32 pub category: Option<String>,
33 #[serde(default)]
34 pub palette: Option<bool>,
35 #[serde(default)]
36 pub total: Option<u32>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(untagged)]
42pub enum IconifyAuthor {
43 Simple(String),
44 Detailed {
45 #[serde(default)]
46 name: Option<String>,
47 #[serde(default)]
48 url: Option<String>,
49 },
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(untagged)]
55pub enum IconifyLicense {
56 Simple(String),
57 Detailed {
58 #[serde(default)]
59 title: Option<String>,
60 #[serde(default)]
61 spdx: Option<String>,
62 #[serde(default)]
63 url: Option<String>,
64 },
65}
66
67#[derive(Debug, Deserialize)]
69struct IconifyApiResponse {
70 icons: HashMap<String, IconifyIcon>,
72 #[serde(default)]
73 width: Option<u32>,
74 #[serde(default)]
75 height: Option<u32>,
76}
77
78pub struct IconifyClient {
80 client: reqwest::Client,
81 base_url: String,
82}
83
84impl IconifyClient {
85 pub fn new() -> Result<Self> {
87 let client = reqwest::Client::builder()
88 .timeout(std::time::Duration::from_secs(30))
89 .build()
90 .context("Failed to create HTTP client")?;
91
92 Ok(Self {
93 client,
94 base_url: API_BASE_URL.to_string(),
95 })
96 }
97
98 pub async fn fetch_collection_info(&self, collection: &str) -> Result<IconifyCollectionInfo> {
100 let url = format!(
101 "{}/collection?prefix={}&info=true",
102 self.base_url, collection
103 );
104
105 let response = self.client.get(&url).send().await.context(format!(
106 "Failed to fetch collection info for '{}'",
107 collection
108 ))?;
109
110 if !response.status().is_success() {
111 let status = response.status();
112 let text = response.text().await.unwrap_or_default();
113 return Err(anyhow!(
114 "API request failed with status {}: {}",
115 status,
116 text
117 ));
118 }
119
120 let collection_info: IconifyCollectionInfo = response
121 .json()
122 .await
123 .context("Failed to parse collection info response")?;
124
125 Ok(collection_info)
126 }
127
128 pub async fn fetch_icon(&self, collection: &str, icon_name: &str) -> Result<IconifyIcon> {
130 let url = format!("{}/{}.json?icons={}", self.base_url, collection, icon_name);
131
132 let response = self
133 .client
134 .get(&url)
135 .send()
136 .await
137 .context(format!("Failed to fetch icon {}:{}", collection, icon_name))?;
138
139 if !response.status().is_success() {
140 let status = response.status();
141 let text = response.text().await.unwrap_or_default();
142 return Err(anyhow!(
143 "API request failed with status {}: {}",
144 status,
145 text
146 ));
147 }
148
149 let api_response: IconifyApiResponse = response
150 .json()
151 .await
152 .context("Failed to parse API response")?;
153
154 let icon = api_response
155 .icons
156 .get(icon_name)
157 .ok_or_else(|| {
158 anyhow!(
159 "Icon '{}' not found in collection '{}'",
160 icon_name,
161 collection
162 )
163 })?
164 .clone();
165
166 let width = icon.width.or(api_response.width).unwrap_or(24);
168 let height = icon.height.or(api_response.height).unwrap_or(24);
169
170 let view_box = icon
172 .view_box
173 .clone()
174 .unwrap_or_else(|| format!("0 0 {} {}", width, height));
175
176 Ok(IconifyIcon {
177 body: icon.body,
178 width: Some(width),
179 height: Some(height),
180 view_box: Some(view_box),
181 })
182 }
183
184 }
239
240impl Default for IconifyClient {
241 fn default() -> Self {
242 Self::new().expect("Failed to create Iconify API client")
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use rstest::rstest;
250
251 #[rstest]
252 #[case("mdi", "home")]
253 #[case("heroicons", "arrow-left")]
254 #[case("lucide", "settings")]
255 #[ignore] #[tokio::test]
257 async fn test_fetch_icon(#[case] collection: &str, #[case] icon_name: &str) {
258 let client = IconifyClient::new().unwrap();
259 let icon = client.fetch_icon(collection, icon_name).await.unwrap();
260
261 assert!(!icon.body.is_empty());
262 assert!(icon.width.is_some());
263 assert!(icon.height.is_some());
264 assert!(icon.view_box.is_some());
265 }
266
267 #[tokio::test]
268 #[ignore] async fn test_fetch_nonexistent_icon() {
270 let client = IconifyClient::new().unwrap();
271 let result = client
272 .fetch_icon("mdi", "this-icon-does-not-exist-12345")
273 .await;
274
275 assert!(result.is_err());
276 }
277
278 #[rstest]
279 #[case("mdi")]
280 #[case("heroicons")]
281 #[case("lucide")]
282 #[ignore] #[tokio::test]
284 async fn test_fetch_collection_info(#[case] collection: &str) {
285 let client = IconifyClient::new().unwrap();
286 let info = client.fetch_collection_info(collection).await.unwrap();
287
288 assert!(info.name.is_some(), "Collection should have a name");
290 }
291}