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: String,
28 /// Additional attributes to extend the svg element
29 #[props(extends = GlobalAttributes)]
30 attributes: Vec<Attribute>,
31 ) -> Element {
32 let (width, height) = if size.is_empty() {
33 (data.width, data.height)
34 } else {
35 (size.as_str(), size.as_str())
36 };
37
38 rsx! {
39 svg {
40 view_box: "{data.view_box}",
41 width: "{width}",
42 height: "{height}",
43 dangerous_inner_html: "{data.body}",
44 ..attributes,
45 }
46 }
47 }
48 "#};
49
50#[derive(Debug, Clone)]
52struct IconConst {
53 name: String,
54 full_icon_name: String,
55 body: String,
56 view_box: String,
57 width: String,
58 height: String,
59}
60
61impl IconConst {
62 fn from_api_icon(identifier: &IconIdentifier, icon: &IconifyIcon) -> Self {
63 Self {
64 name: identifier.to_const_name(),
65 full_icon_name: identifier.full_name.clone(),
66 body: icon.body.clone(),
67 view_box: icon
68 .view_box
69 .clone()
70 .unwrap_or_else(|| "0 0 24 24".to_string()),
71 width: icon.width.unwrap_or(24).to_string(),
72 height: icon.height.unwrap_or(24).to_string(),
73 }
74 }
75
76 fn to_rust_code(&self) -> String {
77 formatdoc! { "
79 #[allow(non_upper_case_globals)]
80 pub const {}: IconData = IconData {{
81 name: \"{}\",
82 body: r#\"{}\"#,
83 view_box: \"{}\",
84 width: \"{}\",
85 height: \"{}\",
86 }};
87 ",
88 self.name,
89 self.full_icon_name,
90 self.body,
91 self.view_box,
92 self.width,
93 self.height
94 }
95 }
96}
97
98pub struct Generator {
100 icons_dir: PathBuf,
101}
102
103impl Generator {
104 pub fn new(icons_dir: PathBuf) -> Self {
106 Self { icons_dir }
107 }
108
109 pub fn list_icons(&self) -> Result<BTreeMap<String, Vec<String>>> {
111 let mut icons_by_collection: BTreeMap<String, Vec<String>> = BTreeMap::new();
112
113 if !self.icons_dir.exists() {
115 return Ok(icons_by_collection);
116 }
117
118 let entries = fs::read_dir(&self.icons_dir).context("Failed to read icons directory")?;
120
121 for entry in entries {
122 let entry = entry.context("Failed to read directory entry")?;
123 let path = entry.path();
124
125 if !path.is_file() || path.file_name() == Some("mod.rs".as_ref()) {
127 continue;
128 }
129
130 if path.extension() != Some("rs".as_ref()) {
132 continue;
133 }
134
135 let icons = self.parse_collection_file(&path)?;
137
138 if let Some(collection_name) = path.file_stem().and_then(|s| s.to_str()) {
140 let icon_names: Vec<String> = icons
141 .values()
142 .map(|icon| icon.full_icon_name.clone())
143 .collect();
144
145 if !icon_names.is_empty() {
146 icons_by_collection.insert(collection_name.to_string(), icon_names);
147 }
148 }
149 }
150
151 Ok(icons_by_collection)
152 }
153
154 pub fn get_all_icon_identifiers(&self) -> Result<Vec<String>> {
156 let icons_by_collection = self.list_icons()?;
157 let mut all_icons = Vec::new();
158
159 for icon_names in icons_by_collection.values() {
160 all_icons.extend(icon_names.clone());
161 }
162
163 Ok(all_icons)
164 }
165
166 pub fn init(&self) -> Result<()> {
168 if !self.icons_dir.exists() {
170 fs::create_dir_all(&self.icons_dir).context("Failed to create icons directory")?;
171 }
172
173 let mod_rs_path = self.icons_dir.join("mod.rs");
175 if !mod_rs_path.exists() {
176 fs::write(&mod_rs_path, MOD_RS_TEMPLATE).context("Failed to create mod.rs")?;
177 }
178
179 Ok(())
180 }
181
182 pub fn add_icons(&self, icons: &[(IconIdentifier, IconifyIcon)]) -> Result<()> {
184 self.init()?;
186
187 let mut icons_by_collection: HashMap<String, Vec<(IconIdentifier, IconifyIcon)>> =
189 HashMap::new();
190
191 for (identifier, icon) in icons {
192 icons_by_collection
193 .entry(identifier.collection.clone())
194 .or_default()
195 .push((identifier.clone(), icon.clone()));
196 }
197
198 for (collection, collection_icons) in &icons_by_collection {
200 self.update_collection_file(collection, collection_icons)?;
201 }
202
203 self.update_mod_rs(&icons_by_collection.keys().cloned().collect::<Vec<_>>())?;
205
206 Ok(())
207 }
208
209 pub fn regenerate_mod_rs(&self) -> Result<()> {
212 let mod_rs_path = self.icons_dir.join("mod.rs");
213
214 if !mod_rs_path.exists() {
216 return self.init();
217 }
218
219 let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
221
222 let mut existing_modules: HashSet<String> = HashSet::new();
224 for line in content.lines() {
225 if line.trim().starts_with("pub mod ")
226 && let Some(module_name) = line
227 .trim()
228 .strip_prefix("pub mod ")
229 .and_then(|s| s.strip_suffix(';'))
230 {
231 existing_modules.insert(module_name.trim().to_string());
232 }
233 }
234
235 let mut new_content = MOD_RS_TEMPLATE.to_string();
237 new_content.push('\n');
238
239 let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
241 sorted_modules.sort();
242
243 for module in sorted_modules {
244 new_content.push_str(&format!("pub mod {};\n", module));
245 }
246
247 fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
248
249 Ok(())
250 }
251
252 fn update_collection_file(
254 &self,
255 collection: &str,
256 new_icons: &[(IconIdentifier, IconifyIcon)],
257 ) -> Result<()> {
258 let module_name = collection.replace('-', "_");
259 let file_path = self.icons_dir.join(format!("{}.rs", module_name));
260
261 let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
263 if file_path.exists() {
264 existing_icons = self.parse_collection_file(&file_path)?;
265 }
266
267 for (identifier, icon) in new_icons {
269 let icon_const = IconConst::from_api_icon(identifier, icon);
270 existing_icons.insert(icon_const.name.clone(), icon_const);
271 }
272
273 let content = self.generate_collection_file(collection, &existing_icons)?;
275
276 fs::write(&file_path, content)
278 .context(format!("Failed to write collection file {:?}", file_path))?;
279
280 println!(
281 "✓ Updated {}.rs with {} icon(s)",
282 module_name,
283 new_icons.len()
284 );
285
286 Ok(())
287 }
288
289 fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
291 let content =
292 fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
293
294 let mut icons = BTreeMap::new();
295
296 let lines: Vec<&str> = content.lines().collect();
299 let mut i = 0;
300
301 while i < lines.len() {
302 let line = lines[i].trim();
303
304 if line.starts_with("pub const ")
306 && line.contains(": IconData")
307 && let Some(name_end) = line.find(':')
308 {
309 let name = line[10..name_end].trim().to_string();
310
311 if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
313 icons.insert(name, icon_const);
314 }
315 }
316
317 i += 1;
318 }
319
320 Ok(icons)
321 }
322
323 fn parse_icon_data(
325 &self,
326 lines: &[&str],
327 index: &mut usize,
328 const_name: &str,
329 ) -> Option<IconConst> {
330 let mut full_icon_name = String::new();
331 let mut body = String::new();
332 let mut view_box = String::new();
333 let mut width = String::new();
334 let mut height = String::new();
335
336 let mut j = *index;
338 while j < lines.len() {
339 let line = lines[j].trim();
340
341 if line.contains("name:") {
342 full_icon_name = extract_string_value(line);
343 } else if line.contains("body:") {
344 body = extract_raw_string_value(lines, &mut j);
346 } else if line.contains("view_box:") {
347 view_box = extract_string_value(line);
348 } else if line.contains("width:") {
349 width = extract_string_value(line);
350 } else if line.contains("height:") {
351 height = extract_string_value(line);
352 }
353
354 if line.contains("};") {
356 break;
357 }
358
359 j += 1;
360 }
361
362 *index = j;
363
364 if !full_icon_name.is_empty() && !body.is_empty() {
365 Some(IconConst {
366 name: const_name.to_string(),
367 full_icon_name,
368 body,
369 view_box,
370 width,
371 height,
372 })
373 } else {
374 None
375 }
376 }
377
378 fn generate_collection_file(
380 &self,
381 collection: &str,
382 icons: &BTreeMap<String, IconConst>,
383 ) -> Result<String> {
384 let mut content = format!(
385 "// Auto-generated by dioxus-iconify - DO NOT EDIT\n// Collection: {}\n\nuse super::IconData;\n\n",
386 collection
387 );
388
389 for icon_const in icons.values() {
391 content.push_str(&icon_const.to_rust_code());
392 content.push_str("\n\n");
393 }
394
395 Ok(content)
396 }
397
398 fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
400 let mod_rs_path = self.icons_dir.join("mod.rs");
401
402 let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
404
405 let mut existing_modules: HashSet<String> = HashSet::new();
407 for line in content.lines() {
408 if line.trim().starts_with("pub mod ")
409 && let Some(module_name) = line
410 .trim()
411 .strip_prefix("pub mod ")
412 .and_then(|s| s.strip_suffix(';'))
413 {
414 existing_modules.insert(module_name.trim().to_string());
415 }
416 }
417
418 let mut needs_update = false;
420 for collection in collections {
421 let module_name = collection.replace('-', "_");
422 if !existing_modules.contains(&module_name) {
423 existing_modules.insert(module_name);
424 needs_update = true;
425 }
426 }
427
428 if needs_update {
430 let mut new_content = MOD_RS_TEMPLATE.to_string();
431 new_content.push('\n');
432
433 let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
435 sorted_modules.sort();
436
437 for module in sorted_modules {
438 new_content.push_str(&format!("pub mod {};\n", module));
439 }
440
441 fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
442 }
443
444 Ok(())
445 }
446}
447
448fn extract_string_value(line: &str) -> String {
450 if let Some(start) = line.find('"')
451 && let Some(end) = line.rfind('"')
452 && end > start
453 {
454 return line[start + 1..end].to_string();
455 }
456 String::new()
457}
458
459fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
461 let line = lines[*index];
462
463 if let Some(start) = line.find("r#\"") {
465 let start_pos = start + 3;
466
467 if let Some(end) = line[start_pos..].find("\"#") {
469 return line[start_pos..start_pos + end].to_string();
470 }
471
472 let mut result = line[start_pos..].to_string();
474 *index += 1;
475
476 while *index < lines.len() {
477 let next_line = lines[*index];
478 if let Some(end) = next_line.find("\"#") {
479 result.push_str(&next_line[..end]);
480 break;
481 }
482 result.push_str(next_line);
483 result.push('\n');
484 *index += 1;
485 }
486
487 result
488 } else {
489 String::new()
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use crate::api::IconifyIcon;
497 use tempfile::TempDir;
498
499 #[test]
500 fn test_list_icons_empty_directory() -> Result<()> {
501 let temp_dir = TempDir::new()?;
502 let generator = Generator::new(temp_dir.path().join("icons"));
503
504 let icons = generator.list_icons()?;
505 assert!(
506 icons.is_empty(),
507 "Should return empty map for non-existent directory"
508 );
509
510 Ok(())
511 }
512
513 #[test]
514 fn test_list_icons_with_generated_icons() -> Result<()> {
515 let temp_dir = TempDir::new()?;
516 let icons_dir = temp_dir.path().join("icons");
517 let generator = Generator::new(icons_dir.clone());
518
519 let test_icon1 = IconifyIcon {
521 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
522 width: Some(24),
523 height: Some(24),
524 view_box: Some("0 0 24 24".to_string()),
525 };
526
527 let test_icon2 = IconifyIcon {
528 body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
529 width: Some(24),
530 height: Some(24),
531 view_box: Some("0 0 24 24".to_string()),
532 };
533
534 let identifier1 = IconIdentifier::parse("mdi:home")?;
535 let identifier2 = IconIdentifier::parse("mdi:settings")?;
536 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
537
538 generator.add_icons(&[
539 (identifier1, test_icon1.clone()),
540 (identifier2, test_icon2.clone()),
541 (identifier3, test_icon1.clone()),
542 ])?;
543
544 let icons = generator.list_icons()?;
546
547 assert_eq!(icons.len(), 2, "Should have 2 collections");
549 assert!(icons.contains_key("mdi"), "Should have mdi collection");
550 assert!(
551 icons.contains_key("heroicons"),
552 "Should have heroicons collection"
553 );
554
555 let mdi_icons = icons.get("mdi").unwrap();
557 assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
558 assert!(mdi_icons.contains(&"mdi:home".to_string()));
559 assert!(mdi_icons.contains(&"mdi:settings".to_string()));
560
561 let heroicons_icons = icons.get("heroicons").unwrap();
563 assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
564 assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
565
566 Ok(())
567 }
568
569 #[test]
570 fn test_get_all_icon_identifiers() -> Result<()> {
571 let temp_dir = TempDir::new()?;
572 let icons_dir = temp_dir.path().join("icons");
573 let generator = Generator::new(icons_dir.clone());
574
575 let empty_icons = generator.get_all_icon_identifiers()?;
577 assert!(
578 empty_icons.is_empty(),
579 "Should return empty vec for no icons"
580 );
581
582 let test_icon = IconifyIcon {
584 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
585 width: Some(24),
586 height: Some(24),
587 view_box: Some("0 0 24 24".to_string()),
588 };
589
590 let identifier1 = IconIdentifier::parse("mdi:home")?;
591 let identifier2 = IconIdentifier::parse("mdi:settings")?;
592 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
593
594 generator.add_icons(&[
595 (identifier1, test_icon.clone()),
596 (identifier2, test_icon.clone()),
597 (identifier3, test_icon.clone()),
598 ])?;
599
600 let all_icons = generator.get_all_icon_identifiers()?;
602
603 assert_eq!(all_icons.len(), 3, "Should have 3 icons");
605 assert!(
606 all_icons.contains(&"mdi:home".to_string()),
607 "Should contain mdi:home"
608 );
609 assert!(
610 all_icons.contains(&"mdi:settings".to_string()),
611 "Should contain mdi:settings"
612 );
613 assert!(
614 all_icons.contains(&"heroicons:arrow-left".to_string()),
615 "Should contain heroicons:arrow-left"
616 );
617
618 Ok(())
619 }
620
621 #[test]
622 fn test_regenerate_mod_rs_updates_template() -> Result<()> {
623 let temp_dir = TempDir::new()?;
624 let icons_dir = temp_dir.path().join("icons");
625 let generator = Generator::new(icons_dir.clone());
626
627 let test_icon = IconifyIcon {
629 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
630 width: Some(24),
631 height: Some(24),
632 view_box: Some("0 0 24 24".to_string()),
633 };
634
635 let identifier1 = IconIdentifier::parse("mdi:home")?;
636 let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
637
638 generator.add_icons(&[
639 (identifier1, test_icon.clone()),
640 (identifier2, test_icon.clone()),
641 ])?;
642
643 let mod_rs_path = icons_dir.join("mod.rs");
644 assert!(mod_rs_path.exists(), "mod.rs should exist");
645
646 let old_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
648use dioxus::prelude::*;
649
650// OLD VERSION WITHOUT SIZE PARAMETER
651#[component]
652pub fn Icon(data: IconData) -> Element {
653 rsx! { svg {} }
654}
655
656pub mod heroicons;
657pub mod mdi;
658"#;
659 fs::write(&mod_rs_path, old_content)?;
660
661 let content_before = fs::read_to_string(&mod_rs_path)?;
663 assert!(
664 content_before.contains("OLD VERSION"),
665 "Should have old version marker"
666 );
667 assert!(
668 !content_before.contains("size: Option<String>"),
669 "Should not have size parameter yet"
670 );
671
672 generator.regenerate_mod_rs()?;
674
675 let content_after = fs::read_to_string(&mod_rs_path)?;
677 assert!(
678 !content_after.contains("OLD VERSION"),
679 "Should not have old version marker"
680 );
681 assert!(
682 content_after.contains("size: String"),
683 "Should have size parameter from latest template"
684 );
685 assert!(
686 content_after.contains("pub mod heroicons;"),
687 "Should preserve heroicons module"
688 );
689 assert!(
690 content_after.contains("pub mod mdi;"),
691 "Should preserve mdi module"
692 );
693
694 Ok(())
695 }
696}