1use anyhow::{Context, Result};
2use indoc::{formatdoc, indoc};
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::api::IconifyIcon;
8use crate::naming::IconIdentifier;
9
10const MOD_RS_TEMPLATE: &str = indoc! {r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
11 use dioxus::prelude::*;
12
13 #[derive(Clone, Copy, PartialEq)]
14 pub struct IconData {
15 pub name: &'static str,
16 pub body: &'static str,
17 pub view_box: &'static str,
18 pub width: &'static str,
19 pub height: &'static str,
20 }
21
22 #[component]
23 pub fn Icon(
24 data: IconData,
25 /// Optional size to set both width and height
26 #[props(default, into)]
27 size: Option<String>,
28 /// Additional attributes to extend the svg element
29 #[props(extends = GlobalAttributes)]
30 attributes: Vec<Attribute>,
31 ) -> Element {
32 let width = size.as_ref().map(|s| s.as_str()).unwrap_or(data.width);
33 let height = size.as_ref().map(|s| s.as_str()).unwrap_or(data.height);
34
35 rsx! {
36 svg {
37 view_box: "{data.view_box}",
38 width: "{width}",
39 height: "{height}",
40 dangerous_inner_html: "{data.body}",
41 ..attributes,
42 }
43 }
44 }
45 "#};
46
47#[derive(Debug, Clone)]
49struct IconConst {
50 name: String,
51 full_icon_name: String,
52 body: String,
53 view_box: String,
54 width: String,
55 height: String,
56}
57
58impl IconConst {
59 fn from_api_icon(identifier: &IconIdentifier, icon: &IconifyIcon) -> Self {
60 Self {
61 name: identifier.to_const_name(),
62 full_icon_name: identifier.full_name.clone(),
63 body: icon.body.clone(),
64 view_box: icon
65 .view_box
66 .clone()
67 .unwrap_or_else(|| "0 0 24 24".to_string()),
68 width: icon.width.unwrap_or(24).to_string(),
69 height: icon.height.unwrap_or(24).to_string(),
70 }
71 }
72
73 fn to_rust_code(&self) -> String {
74 formatdoc! { "
76 #[allow(non_upper_case_globals)]
77 pub const {}: IconData = IconData {{
78 name: \"{}\",
79 body: r#\"{}\"#,
80 view_box: \"{}\",
81 width: \"{}\",
82 height: \"{}\",
83 }};
84 ",
85 self.name,
86 self.full_icon_name,
87 self.body,
88 self.view_box,
89 self.width,
90 self.height
91 }
92 }
93}
94
95pub struct Generator {
97 icons_dir: PathBuf,
98}
99
100impl Generator {
101 pub fn new(icons_dir: PathBuf) -> Self {
103 Self { icons_dir }
104 }
105
106 pub fn list_icons(&self) -> Result<BTreeMap<String, Vec<String>>> {
108 let mut icons_by_collection: BTreeMap<String, Vec<String>> = BTreeMap::new();
109
110 if !self.icons_dir.exists() {
112 return Ok(icons_by_collection);
113 }
114
115 let entries = fs::read_dir(&self.icons_dir).context("Failed to read icons directory")?;
117
118 for entry in entries {
119 let entry = entry.context("Failed to read directory entry")?;
120 let path = entry.path();
121
122 if !path.is_file() || path.file_name() == Some("mod.rs".as_ref()) {
124 continue;
125 }
126
127 if path.extension() != Some("rs".as_ref()) {
129 continue;
130 }
131
132 let icons = self.parse_collection_file(&path)?;
134
135 if let Some(collection_name) = path.file_stem().and_then(|s| s.to_str()) {
137 let icon_names: Vec<String> = icons
138 .values()
139 .map(|icon| icon.full_icon_name.clone())
140 .collect();
141
142 if !icon_names.is_empty() {
143 icons_by_collection.insert(collection_name.to_string(), icon_names);
144 }
145 }
146 }
147
148 Ok(icons_by_collection)
149 }
150
151 pub fn get_all_icon_identifiers(&self) -> Result<Vec<String>> {
153 let icons_by_collection = self.list_icons()?;
154 let mut all_icons = Vec::new();
155
156 for icon_names in icons_by_collection.values() {
157 all_icons.extend(icon_names.clone());
158 }
159
160 Ok(all_icons)
161 }
162
163 pub fn init(&self) -> Result<()> {
165 if !self.icons_dir.exists() {
167 fs::create_dir_all(&self.icons_dir).context("Failed to create icons directory")?;
168 }
169
170 let mod_rs_path = self.icons_dir.join("mod.rs");
172 if !mod_rs_path.exists() {
173 fs::write(&mod_rs_path, MOD_RS_TEMPLATE).context("Failed to create mod.rs")?;
174 }
175
176 Ok(())
177 }
178
179 pub fn add_icons(&self, icons: &[(IconIdentifier, IconifyIcon)]) -> Result<()> {
181 self.init()?;
183
184 let mut icons_by_collection: HashMap<String, Vec<(IconIdentifier, IconifyIcon)>> =
186 HashMap::new();
187
188 for (identifier, icon) in icons {
189 icons_by_collection
190 .entry(identifier.collection.clone())
191 .or_default()
192 .push((identifier.clone(), icon.clone()));
193 }
194
195 for (collection, collection_icons) in &icons_by_collection {
197 self.update_collection_file(collection, collection_icons)?;
198 }
199
200 self.update_mod_rs(&icons_by_collection.keys().cloned().collect::<Vec<_>>())?;
202
203 Ok(())
204 }
205
206 fn update_collection_file(
208 &self,
209 collection: &str,
210 new_icons: &[(IconIdentifier, IconifyIcon)],
211 ) -> Result<()> {
212 let module_name = collection.replace('-', "_");
213 let file_path = self.icons_dir.join(format!("{}.rs", module_name));
214
215 let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
217 if file_path.exists() {
218 existing_icons = self.parse_collection_file(&file_path)?;
219 }
220
221 for (identifier, icon) in new_icons {
223 let icon_const = IconConst::from_api_icon(identifier, icon);
224 existing_icons.insert(icon_const.name.clone(), icon_const);
225 }
226
227 let content = self.generate_collection_file(collection, &existing_icons)?;
229
230 fs::write(&file_path, content)
232 .context(format!("Failed to write collection file {:?}", file_path))?;
233
234 println!(
235 "✓ Updated {}.rs with {} icon(s)",
236 module_name,
237 new_icons.len()
238 );
239
240 Ok(())
241 }
242
243 fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
245 let content =
246 fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
247
248 let mut icons = BTreeMap::new();
249
250 let lines: Vec<&str> = content.lines().collect();
253 let mut i = 0;
254
255 while i < lines.len() {
256 let line = lines[i].trim();
257
258 if line.starts_with("pub const ")
260 && line.contains(": IconData")
261 && let Some(name_end) = line.find(':')
262 {
263 let name = line[10..name_end].trim().to_string();
264
265 if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
267 icons.insert(name, icon_const);
268 }
269 }
270
271 i += 1;
272 }
273
274 Ok(icons)
275 }
276
277 fn parse_icon_data(
279 &self,
280 lines: &[&str],
281 index: &mut usize,
282 const_name: &str,
283 ) -> Option<IconConst> {
284 let mut full_icon_name = String::new();
285 let mut body = String::new();
286 let mut view_box = String::new();
287 let mut width = String::new();
288 let mut height = String::new();
289
290 let mut j = *index;
292 while j < lines.len() {
293 let line = lines[j].trim();
294
295 if line.contains("name:") {
296 full_icon_name = extract_string_value(line);
297 } else if line.contains("body:") {
298 body = extract_raw_string_value(lines, &mut j);
300 } else if line.contains("view_box:") {
301 view_box = extract_string_value(line);
302 } else if line.contains("width:") {
303 width = extract_string_value(line);
304 } else if line.contains("height:") {
305 height = extract_string_value(line);
306 }
307
308 if line.contains("};") {
310 break;
311 }
312
313 j += 1;
314 }
315
316 *index = j;
317
318 if !full_icon_name.is_empty() && !body.is_empty() {
319 Some(IconConst {
320 name: const_name.to_string(),
321 full_icon_name,
322 body,
323 view_box,
324 width,
325 height,
326 })
327 } else {
328 None
329 }
330 }
331
332 fn generate_collection_file(
334 &self,
335 collection: &str,
336 icons: &BTreeMap<String, IconConst>,
337 ) -> Result<String> {
338 let mut content = format!(
339 "// Auto-generated by dioxus-iconify - DO NOT EDIT\n// Collection: {}\n\nuse super::IconData;\n\n",
340 collection
341 );
342
343 for icon_const in icons.values() {
345 content.push_str(&icon_const.to_rust_code());
346 content.push_str("\n\n");
347 }
348
349 Ok(content)
350 }
351
352 fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
354 let mod_rs_path = self.icons_dir.join("mod.rs");
355
356 let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
358
359 let mut existing_modules: HashSet<String> = HashSet::new();
361 for line in content.lines() {
362 if line.trim().starts_with("pub mod ")
363 && let Some(module_name) = line
364 .trim()
365 .strip_prefix("pub mod ")
366 .and_then(|s| s.strip_suffix(';'))
367 {
368 existing_modules.insert(module_name.trim().to_string());
369 }
370 }
371
372 let mut needs_update = false;
374 for collection in collections {
375 let module_name = collection.replace('-', "_");
376 if !existing_modules.contains(&module_name) {
377 existing_modules.insert(module_name);
378 needs_update = true;
379 }
380 }
381
382 if needs_update {
384 let mut new_content = MOD_RS_TEMPLATE.to_string();
385 new_content.push('\n');
386
387 let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
389 sorted_modules.sort();
390
391 for module in sorted_modules {
392 new_content.push_str(&format!("pub mod {};\n", module));
393 }
394
395 fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
396 }
397
398 Ok(())
399 }
400}
401
402fn extract_string_value(line: &str) -> String {
404 if let Some(start) = line.find('"')
405 && let Some(end) = line.rfind('"')
406 && end > start
407 {
408 return line[start + 1..end].to_string();
409 }
410 String::new()
411}
412
413fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
415 let line = lines[*index];
416
417 if let Some(start) = line.find("r#\"") {
419 let start_pos = start + 3;
420
421 if let Some(end) = line[start_pos..].find("\"#") {
423 return line[start_pos..start_pos + end].to_string();
424 }
425
426 let mut result = line[start_pos..].to_string();
428 *index += 1;
429
430 while *index < lines.len() {
431 let next_line = lines[*index];
432 if let Some(end) = next_line.find("\"#") {
433 result.push_str(&next_line[..end]);
434 break;
435 }
436 result.push_str(next_line);
437 result.push('\n');
438 *index += 1;
439 }
440
441 result
442 } else {
443 String::new()
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::api::IconifyIcon;
451 use tempfile::TempDir;
452
453 #[test]
454 fn test_list_icons_empty_directory() -> Result<()> {
455 let temp_dir = TempDir::new()?;
456 let generator = Generator::new(temp_dir.path().join("icons"));
457
458 let icons = generator.list_icons()?;
459 assert!(
460 icons.is_empty(),
461 "Should return empty map for non-existent directory"
462 );
463
464 Ok(())
465 }
466
467 #[test]
468 fn test_list_icons_with_generated_icons() -> Result<()> {
469 let temp_dir = TempDir::new()?;
470 let icons_dir = temp_dir.path().join("icons");
471 let generator = Generator::new(icons_dir.clone());
472
473 let test_icon1 = IconifyIcon {
475 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
476 width: Some(24),
477 height: Some(24),
478 view_box: Some("0 0 24 24".to_string()),
479 };
480
481 let test_icon2 = IconifyIcon {
482 body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
483 width: Some(24),
484 height: Some(24),
485 view_box: Some("0 0 24 24".to_string()),
486 };
487
488 let identifier1 = IconIdentifier::parse("mdi:home")?;
489 let identifier2 = IconIdentifier::parse("mdi:settings")?;
490 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
491
492 generator.add_icons(&[
493 (identifier1, test_icon1.clone()),
494 (identifier2, test_icon2.clone()),
495 (identifier3, test_icon1.clone()),
496 ])?;
497
498 let icons = generator.list_icons()?;
500
501 assert_eq!(icons.len(), 2, "Should have 2 collections");
503 assert!(icons.contains_key("mdi"), "Should have mdi collection");
504 assert!(
505 icons.contains_key("heroicons"),
506 "Should have heroicons collection"
507 );
508
509 let mdi_icons = icons.get("mdi").unwrap();
511 assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
512 assert!(mdi_icons.contains(&"mdi:home".to_string()));
513 assert!(mdi_icons.contains(&"mdi:settings".to_string()));
514
515 let heroicons_icons = icons.get("heroicons").unwrap();
517 assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
518 assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
519
520 Ok(())
521 }
522
523 #[test]
524 fn test_get_all_icon_identifiers() -> Result<()> {
525 let temp_dir = TempDir::new()?;
526 let icons_dir = temp_dir.path().join("icons");
527 let generator = Generator::new(icons_dir.clone());
528
529 let empty_icons = generator.get_all_icon_identifiers()?;
531 assert!(
532 empty_icons.is_empty(),
533 "Should return empty vec for no icons"
534 );
535
536 let test_icon = IconifyIcon {
538 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
539 width: Some(24),
540 height: Some(24),
541 view_box: Some("0 0 24 24".to_string()),
542 };
543
544 let identifier1 = IconIdentifier::parse("mdi:home")?;
545 let identifier2 = IconIdentifier::parse("mdi:settings")?;
546 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
547
548 generator.add_icons(&[
549 (identifier1, test_icon.clone()),
550 (identifier2, test_icon.clone()),
551 (identifier3, test_icon.clone()),
552 ])?;
553
554 let all_icons = generator.get_all_icon_identifiers()?;
556
557 assert_eq!(all_icons.len(), 3, "Should have 3 icons");
559 assert!(
560 all_icons.contains(&"mdi:home".to_string()),
561 "Should contain mdi:home"
562 );
563 assert!(
564 all_icons.contains(&"mdi:settings".to_string()),
565 "Should contain mdi:settings"
566 );
567 assert!(
568 all_icons.contains(&"heroicons:arrow-left".to_string()),
569 "Should contain heroicons:arrow-left"
570 );
571
572 Ok(())
573 }
574}