1use anyhow::{Context, Result};
2use indoc::{formatdoc, indoc};
3use std::collections::{BTreeMap, HashMap};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::api::{IconifyCollectionInfo, 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
80 #[allow(non_upper_case_globals)]
81 pub const {}: IconData = IconData {{
82 name: \"{}\",
83 body: r#\"{}\"#,
84 view_box: \"{}\",
85 width: \"{}\",
86 height: \"{}\",
87 }};
88 ",
89 self.name,
90 self.full_icon_name,
91 self.body,
92 self.view_box,
93 self.width,
94 self.height
95 }
96 }
97}
98
99pub struct Generator {
101 icons_dir: PathBuf,
102}
103
104impl Generator {
105 pub fn new(icons_dir: PathBuf) -> Self {
107 Self { icons_dir }
108 }
109
110 pub fn list_icons(&self) -> Result<BTreeMap<String, Vec<String>>> {
112 let mut icons_by_collection: BTreeMap<String, Vec<String>> = BTreeMap::new();
113
114 if !self.icons_dir.exists() {
116 return Ok(icons_by_collection);
117 }
118
119 let entries = fs::read_dir(&self.icons_dir).context("Failed to read icons directory")?;
121
122 for entry in entries {
123 let entry = entry.context("Failed to read directory entry")?;
124 let path = entry.path();
125
126 if !path.is_file() || path.file_name() == Some("mod.rs".as_ref()) {
128 continue;
129 }
130
131 if path.extension() != Some("rs".as_ref()) {
133 continue;
134 }
135
136 let icons = self.parse_collection_file(&path)?;
138
139 if let Some(collection_name) = path.file_stem().and_then(|s| s.to_str()) {
141 let icon_names: Vec<String> = icons
142 .values()
143 .map(|icon| icon.full_icon_name.clone())
144 .collect();
145
146 if !icon_names.is_empty() {
147 icons_by_collection.insert(collection_name.to_string(), icon_names);
148 }
149 }
150 }
151
152 Ok(icons_by_collection)
153 }
154
155 pub fn get_all_icon_identifiers(&self) -> Result<Vec<String>> {
157 let icons_by_collection = self.list_icons()?;
158 let mut all_icons = Vec::new();
159
160 for icon_names in icons_by_collection.values() {
161 all_icons.extend(icon_names.clone());
162 }
163
164 Ok(all_icons)
165 }
166
167 pub fn init(&self) -> Result<()> {
169 if !self.icons_dir.exists() {
171 fs::create_dir_all(&self.icons_dir).context("Failed to create icons directory")?;
172 }
173
174 let mod_rs_path = self.icons_dir.join("mod.rs");
176 if !mod_rs_path.exists() {
177 fs::write(&mod_rs_path, MOD_RS_TEMPLATE).context("Failed to create mod.rs")?;
178 }
179
180 Ok(())
181 }
182
183 pub fn add_icons(
185 &self,
186 icons: &[(IconIdentifier, IconifyIcon)],
187 collection_info: &HashMap<String, IconifyCollectionInfo>,
188 ) -> Result<()> {
189 self.init()?;
191
192 let mut icons_by_collection: HashMap<String, Vec<(IconIdentifier, IconifyIcon)>> =
194 HashMap::new();
195
196 for (identifier, icon) in icons {
197 icons_by_collection
198 .entry(identifier.collection.clone())
199 .or_default()
200 .push((identifier.clone(), icon.clone()));
201 }
202
203 for (collection, collection_icons) in &icons_by_collection {
205 let info = collection_info.get(collection);
206 self.update_collection_file(collection, collection_icons, info)?;
207 }
208
209 self.update_mod_rs(&icons_by_collection.keys().cloned().collect::<Vec<_>>())?;
211
212 Ok(())
213 }
214
215 pub fn regenerate_mod_rs(&self) -> Result<()> {
218 let mod_rs_path = self.icons_dir.join("mod.rs");
219
220 if !mod_rs_path.exists() {
222 return self.init();
223 }
224
225 let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
227
228 let existing_modules = extract_module_declarations(&content);
230
231 let mut new_content = MOD_RS_TEMPLATE.to_string();
233 new_content.push('\n');
234
235 let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
237 sorted_modules.sort_by_key(|(name, _)| *name);
238
239 for (module, visibility) in sorted_modules {
240 new_content.push_str(&format!("{}mod {};\n", visibility, module));
241 }
242
243 fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
244
245 Ok(())
246 }
247
248 fn update_collection_file(
250 &self,
251 collection: &str,
252 new_icons: &[(IconIdentifier, IconifyIcon)],
253 collection_info: Option<&IconifyCollectionInfo>,
254 ) -> Result<()> {
255 let module_name = collection.replace('-', "_");
256 let file_path = self.icons_dir.join(format!("{}.rs", module_name));
257
258 let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
260 if file_path.exists() {
261 existing_icons = self.parse_collection_file(&file_path)?;
262 }
263
264 for (identifier, icon) in new_icons {
266 let icon_const = IconConst::from_api_icon(identifier, icon);
267 existing_icons.insert(icon_const.name.clone(), icon_const);
268 }
269
270 let content =
272 self.generate_collection_file(collection, &existing_icons, collection_info)?;
273
274 fs::write(&file_path, content)
276 .context(format!("Failed to write collection file {:?}", file_path))?;
277
278 println!(
279 "✓ Updated {}.rs with {} icon(s)",
280 module_name,
281 new_icons.len()
282 );
283
284 Ok(())
285 }
286
287 fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
289 let content =
290 fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
291
292 let mut icons = BTreeMap::new();
293
294 let lines: Vec<&str> = content.lines().collect();
297 let mut i = 0;
298
299 while i < lines.len() {
300 let line = lines[i].trim();
301
302 if line.starts_with("pub const ")
304 && line.contains(": IconData")
305 && let Some(name_end) = line.find(':')
306 {
307 let name = line[10..name_end].trim().to_string();
308
309 if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
311 icons.insert(name, icon_const);
312 }
313 }
314
315 i += 1;
316 }
317
318 Ok(icons)
319 }
320
321 fn parse_icon_data(
323 &self,
324 lines: &[&str],
325 index: &mut usize,
326 const_name: &str,
327 ) -> Option<IconConst> {
328 let mut full_icon_name = String::new();
329 let mut body = String::new();
330 let mut view_box = String::new();
331 let mut width = String::new();
332 let mut height = String::new();
333
334 let mut j = *index;
336 while j < lines.len() {
337 let line = lines[j].trim();
338
339 if line.contains("name:") {
340 full_icon_name = extract_string_value(line);
341 } else if line.contains("body:") {
342 body = extract_raw_string_value(lines, &mut j);
344 } else if line.contains("view_box:") {
345 view_box = extract_string_value(line);
346 } else if line.contains("width:") {
347 width = extract_string_value(line);
348 } else if line.contains("height:") {
349 height = extract_string_value(line);
350 }
351
352 if line.contains("};") {
354 break;
355 }
356
357 j += 1;
358 }
359
360 *index = j;
361
362 if !full_icon_name.is_empty() && !body.is_empty() {
363 Some(IconConst {
364 name: const_name.to_string(),
365 full_icon_name,
366 body,
367 view_box,
368 width,
369 height,
370 })
371 } else {
372 None
373 }
374 }
375
376 fn generate_collection_file(
378 &self,
379 collection: &str,
380 icons: &BTreeMap<String, IconConst>,
381 collection_info: Option<&IconifyCollectionInfo>,
382 ) -> Result<String> {
383 let mut content = String::from("/// Auto-generated by dioxus-iconify - DO NOT EDIT\n");
384
385 let now = chrono::Utc::now();
387 content.push_str(&format!("/// Generated: {}\n", now.to_rfc3339()));
388
389 content.push_str(&format!("/// Collection: {}\n", collection));
390 content.push_str("/// This is a partial import from Iconify\n");
391 content.push_str(&format!(
392 "/// Browse icons: https://icon-sets.iconify.design/{}/\n",
393 collection
394 ));
395
396 if let Some(info) = collection_info {
398 content.push_str("///\n");
399 content.push_str(&format_collection_info_comment(info));
400 }
401
402 content.push_str("use super::IconData;\n");
403
404 for icon_const in icons.values() {
406 content.push_str(&icon_const.to_rust_code());
407 }
408
409 Ok(content)
410 }
411
412 fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
414 let mod_rs_path = self.icons_dir.join("mod.rs");
415
416 let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
418
419 let mut existing_modules = extract_module_declarations(&content);
421
422 let mut needs_update = false;
424 for collection in collections {
425 let module_name = collection.replace('-', "_");
426 if let std::collections::hash_map::Entry::Vacant(e) =
427 existing_modules.entry(module_name)
428 {
429 e.insert("pub ".to_string());
430 needs_update = true;
431 }
432 }
433
434 if needs_update {
436 let mut new_content = MOD_RS_TEMPLATE.to_string();
437 new_content.push('\n');
438
439 let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
441 sorted_modules.sort_by_key(|(name, _)| *name);
442
443 for (module, visibility) in sorted_modules {
444 new_content.push_str(&format!("{}mod {};\n", visibility, module));
445 }
446
447 fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
448 }
449
450 Ok(())
451 }
452}
453
454fn extract_module_declarations(content: &str) -> HashMap<String, String> {
458 let mut modules = HashMap::new();
459
460 for line in content.lines() {
461 let trimmed = line.trim();
462
463 if !trimmed.ends_with(';') {
465 continue;
466 }
467
468 let without_semi = &trimmed[..trimmed.len() - 1];
470
471 if let Some(mod_idx) = without_semi.find("mod ") {
473 let visibility = if mod_idx == 0 {
475 String::new()
477 } else {
478 let vis = without_semi[..mod_idx].trim();
481 if vis.is_empty() {
482 String::new()
483 } else {
484 vis.to_string() + " "
485 }
486 };
487
488 let module_name = without_semi[mod_idx + 4..].trim();
490
491 if !module_name.is_empty() {
493 modules.insert(module_name.to_string(), visibility);
494 }
495 }
496 }
497
498 modules
499}
500
501fn format_collection_info_comment(info: &IconifyCollectionInfo) -> String {
503 use crate::api::{IconifyAuthor, IconifyLicense};
504
505 let mut lines = Vec::new();
506 lines.push("/// ```yaml".to_string());
507
508 if let Some(name) = &info.name {
509 lines.push(format!("/// name: {}", name));
510 }
511
512 if let Some(author) = &info.author {
513 match author {
514 IconifyAuthor::Simple(s) => {
515 lines.push(format!("/// author: {}", s));
516 }
517 IconifyAuthor::Detailed { name, url } => {
518 lines.push("/// author:".to_string());
519 if let Some(n) = name {
520 lines.push(format!("/// name: {}", n));
521 }
522 if let Some(u) = url {
523 lines.push(format!("/// url: {}", u));
524 }
525 }
526 }
527 }
528
529 if let Some(license) = &info.license {
530 match license {
531 IconifyLicense::Simple(s) => {
532 lines.push(format!("/// license: {}", s));
533 }
534 IconifyLicense::Detailed { title, spdx, url } => {
535 lines.push("/// license:".to_string());
536 if let Some(t) = title {
537 lines.push(format!("/// title: {}", t));
538 }
539 if let Some(s) = spdx {
540 lines.push(format!("/// spdx: {}", s));
541 }
542 if let Some(u) = url {
543 lines.push(format!("/// url: {}", u));
544 }
545 }
546 }
547 }
548
549 if let Some(total) = info.total {
550 lines.push(format!("/// total: {}", total));
551 }
552
553 if let Some(category) = &info.category {
554 lines.push(format!("/// category: {}", category));
555 }
556
557 if let Some(palette) = info.palette {
558 lines.push(format!("/// palette: {}", palette));
559 }
560
561 if let Some(height) = info.height {
562 lines.push(format!("/// height: {}", height));
563 }
564
565 lines.push("/// ```".to_string());
566
567 lines.join("\n") + "\n"
568}
569
570fn extract_string_value(line: &str) -> String {
572 if let Some(start) = line.find('"')
573 && let Some(end) = line.rfind('"')
574 && end > start
575 {
576 return line[start + 1..end].to_string();
577 }
578 String::new()
579}
580
581fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
583 let line = lines[*index];
584
585 if let Some(start) = line.find("r#\"") {
587 let start_pos = start + 3;
588
589 if let Some(end) = line[start_pos..].find("\"#") {
591 return line[start_pos..start_pos + end].to_string();
592 }
593
594 let mut result = line[start_pos..].to_string();
596 *index += 1;
597
598 while *index < lines.len() {
599 let next_line = lines[*index];
600 if let Some(end) = next_line.find("\"#") {
601 result.push_str(&next_line[..end]);
602 break;
603 }
604 result.push_str(next_line);
605 result.push('\n');
606 *index += 1;
607 }
608
609 result
610 } else {
611 String::new()
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::api::IconifyIcon;
619 use tempfile::TempDir;
620
621 #[test]
622 fn test_list_icons_empty_directory() -> Result<()> {
623 let temp_dir = TempDir::new()?;
624 let generator = Generator::new(temp_dir.path().join("icons"));
625
626 let icons = generator.list_icons()?;
627 assert!(
628 icons.is_empty(),
629 "Should return empty map for non-existent directory"
630 );
631
632 Ok(())
633 }
634
635 #[test]
636 fn test_list_icons_with_generated_icons() -> Result<()> {
637 let temp_dir = TempDir::new()?;
638 let icons_dir = temp_dir.path().join("icons");
639 let generator = Generator::new(icons_dir.clone());
640
641 let test_icon1 = IconifyIcon {
643 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
644 width: Some(24),
645 height: Some(24),
646 view_box: Some("0 0 24 24".to_string()),
647 };
648
649 let test_icon2 = IconifyIcon {
650 body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
651 width: Some(24),
652 height: Some(24),
653 view_box: Some("0 0 24 24".to_string()),
654 };
655
656 let identifier1 = IconIdentifier::parse("mdi:home")?;
657 let identifier2 = IconIdentifier::parse("mdi:settings")?;
658 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
659
660 generator.add_icons(
661 &[
662 (identifier1, test_icon1.clone()),
663 (identifier2, test_icon2.clone()),
664 (identifier3, test_icon1.clone()),
665 ],
666 &HashMap::new(),
667 )?;
668
669 let icons = generator.list_icons()?;
671
672 assert_eq!(icons.len(), 2, "Should have 2 collections");
674 assert!(icons.contains_key("mdi"), "Should have mdi collection");
675 assert!(
676 icons.contains_key("heroicons"),
677 "Should have heroicons collection"
678 );
679
680 let mdi_icons = icons.get("mdi").unwrap();
682 assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
683 assert!(mdi_icons.contains(&"mdi:home".to_string()));
684 assert!(mdi_icons.contains(&"mdi:settings".to_string()));
685
686 let heroicons_icons = icons.get("heroicons").unwrap();
688 assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
689 assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
690
691 Ok(())
692 }
693
694 #[test]
695 fn test_get_all_icon_identifiers() -> Result<()> {
696 let temp_dir = TempDir::new()?;
697 let icons_dir = temp_dir.path().join("icons");
698 let generator = Generator::new(icons_dir.clone());
699
700 let empty_icons = generator.get_all_icon_identifiers()?;
702 assert!(
703 empty_icons.is_empty(),
704 "Should return empty vec for no icons"
705 );
706
707 let test_icon = IconifyIcon {
709 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
710 width: Some(24),
711 height: Some(24),
712 view_box: Some("0 0 24 24".to_string()),
713 };
714
715 let identifier1 = IconIdentifier::parse("mdi:home")?;
716 let identifier2 = IconIdentifier::parse("mdi:settings")?;
717 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
718
719 generator.add_icons(
720 &[
721 (identifier1, test_icon.clone()),
722 (identifier2, test_icon.clone()),
723 (identifier3, test_icon.clone()),
724 ],
725 &HashMap::new(),
726 )?;
727
728 let all_icons = generator.get_all_icon_identifiers()?;
730
731 assert_eq!(all_icons.len(), 3, "Should have 3 icons");
733 assert!(
734 all_icons.contains(&"mdi:home".to_string()),
735 "Should contain mdi:home"
736 );
737 assert!(
738 all_icons.contains(&"mdi:settings".to_string()),
739 "Should contain mdi:settings"
740 );
741 assert!(
742 all_icons.contains(&"heroicons:arrow-left".to_string()),
743 "Should contain heroicons:arrow-left"
744 );
745
746 Ok(())
747 }
748
749 #[test]
750 fn test_regenerate_mod_rs_updates_template() -> Result<()> {
751 let temp_dir = TempDir::new()?;
752 let icons_dir = temp_dir.path().join("icons");
753 let generator = Generator::new(icons_dir.clone());
754
755 let test_icon = IconifyIcon {
757 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
758 width: Some(24),
759 height: Some(24),
760 view_box: Some("0 0 24 24".to_string()),
761 };
762
763 let identifier1 = IconIdentifier::parse("mdi:home")?;
764 let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
765
766 generator.add_icons(
767 &[
768 (identifier1, test_icon.clone()),
769 (identifier2, test_icon.clone()),
770 ],
771 &HashMap::new(),
772 )?;
773
774 let mod_rs_path = icons_dir.join("mod.rs");
775 assert!(mod_rs_path.exists(), "mod.rs should exist");
776
777 let old_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
779use dioxus::prelude::*;
780
781// OLD VERSION WITHOUT SIZE PARAMETER
782#[component]
783pub fn Icon(data: IconData) -> Element {
784 rsx! { svg {} }
785}
786
787pub mod heroicons;
788pub mod mdi;
789"#;
790 fs::write(&mod_rs_path, old_content)?;
791
792 let content_before = fs::read_to_string(&mod_rs_path)?;
794 assert!(
795 content_before.contains("OLD VERSION"),
796 "Should have old version marker"
797 );
798 assert!(
799 !content_before.contains("size: Option<String>"),
800 "Should not have size parameter yet"
801 );
802
803 generator.regenerate_mod_rs()?;
805
806 let content_after = fs::read_to_string(&mod_rs_path)?;
808 assert!(
809 !content_after.contains("OLD VERSION"),
810 "Should not have old version marker"
811 );
812 assert!(
813 content_after.contains("size: String"),
814 "Should have size parameter from latest template"
815 );
816 assert!(
817 content_after.contains("pub mod heroicons;"),
818 "Should preserve heroicons module"
819 );
820 assert!(
821 content_after.contains("pub mod mdi;"),
822 "Should preserve mdi module"
823 );
824
825 Ok(())
826 }
827
828 #[test]
829 fn test_collection_info_in_generated_file() -> Result<()> {
830 use crate::api::{IconifyAuthor, IconifyCollectionInfo, IconifyLicense};
831
832 let temp_dir = TempDir::new()?;
833 let icons_dir = temp_dir.path().join("icons");
834 let generator = Generator::new(icons_dir.clone());
835
836 let test_icon = IconifyIcon {
838 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
839 width: Some(24),
840 height: Some(24),
841 view_box: Some("0 0 24 24".to_string()),
842 };
843
844 let identifier = IconIdentifier::parse("mdi:home")?;
845
846 let mut collection_info = HashMap::new();
848 collection_info.insert(
849 "mdi".to_string(),
850 IconifyCollectionInfo {
851 name: Some("Material Design Icons".to_string()),
852 author: Some(IconifyAuthor::Detailed {
853 name: Some("Pictogrammers".to_string()),
854 url: Some("https://pictogrammers.com".to_string()),
855 }),
856 license: Some(IconifyLicense::Detailed {
857 title: Some("Apache 2.0".to_string()),
858 spdx: Some("Apache-2.0".to_string()),
859 url: Some("https://www.apache.org/licenses/LICENSE-2.0".to_string()),
860 }),
861 total: Some(7000),
862 category: Some("General".to_string()),
863 palette: Some(false),
864 height: Some(24),
865 },
866 );
867
868 generator.add_icons(&[(identifier, test_icon)], &collection_info)?;
870
871 let generated_file = icons_dir.join("mdi.rs");
873 let content = fs::read_to_string(&generated_file)?;
874
875 assert!(
877 content.contains("/// Generated:"),
878 "Should include generation timestamp"
879 );
880 assert!(
881 content.contains("/// Collection: mdi"),
882 "Should include collection name"
883 );
884 assert!(
885 content.contains("/// This is a partial import from Iconify"),
886 "Should indicate partial import"
887 );
888 assert!(
889 content.contains("/// Browse icons: https://icon-sets.iconify.design/mdi/"),
890 "Should include browse URL"
891 );
892
893 assert!(
895 content.contains("/// ```yaml"),
896 "Should include YAML code block"
897 );
898 assert!(
899 content.contains("name: Material Design Icons"),
900 "Should include collection name"
901 );
902 assert!(content.contains("author:"), "Should include author section");
903 assert!(
904 content.contains("name: Pictogrammers"),
905 "Should include author name"
906 );
907 assert!(
908 content.contains("url: https://pictogrammers.com"),
909 "Should include author URL"
910 );
911 assert!(
912 content.contains("license:"),
913 "Should include license section"
914 );
915 assert!(
916 content.contains("title: Apache 2.0"),
917 "Should include license title"
918 );
919 assert!(
920 content.contains("spdx: Apache-2.0"),
921 "Should include SPDX identifier"
922 );
923 assert!(
924 content.contains("total: 7000"),
925 "Should include total count"
926 );
927 assert!(
928 content.contains("category: General"),
929 "Should include category"
930 );
931 assert!(content.contains("palette: false"), "Should include palette");
932 assert!(content.contains("height: 24"), "Should include height");
933
934 Ok(())
935 }
936
937 #[test]
938 fn test_module_visibility_preservation() -> Result<()> {
939 let temp_dir = TempDir::new()?;
940 let icons_dir = temp_dir.path().join("icons");
941 let generator = Generator::new(icons_dir.clone());
942
943 let test_icon = IconifyIcon {
945 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
946 width: Some(24),
947 height: Some(24),
948 view_box: Some("0 0 24 24".to_string()),
949 };
950
951 let identifier1 = IconIdentifier::parse("mdi:home")?;
952 let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
953
954 generator.add_icons(
955 &[
956 (identifier1, test_icon.clone()),
957 (identifier2, test_icon.clone()),
958 ],
959 &HashMap::new(),
960 )?;
961
962 let mod_rs_path = icons_dir.join("mod.rs");
963
964 let modified_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
966use dioxus::prelude::*;
967
968#[derive(Clone, Copy, PartialEq)]
969pub struct IconData {
970 pub name: &'static str,
971 pub body: &'static str,
972 pub view_box: &'static str,
973 pub width: &'static str,
974 pub height: &'static str,
975}
976
977#[component]
978pub fn Icon(
979 data: IconData,
980 /// Optional size to set both width and height
981 #[props(default, into)]
982 size: String,
983 /// Additional attributes to extend the svg element
984 #[props(extends = GlobalAttributes)]
985 attributes: Vec<Attribute>,
986) -> Element {
987 let (width, height) = if size.is_empty() {
988 (data.width, data.height)
989 } else {
990 (size.as_str(), size.as_str())
991 };
992
993 rsx! {
994 svg {
995 view_box: "{data.view_box}",
996 width: "{width}",
997 height: "{height}",
998 dangerous_inner_html: "{data.body}",
999 ..attributes,
1000 }
1001 }
1002}
1003
1004mod heroicons;
1005pub mod mdi;
1006"#;
1007 fs::write(&mod_rs_path, modified_content)?;
1008
1009 let identifier3 = IconIdentifier::parse("lucide:star")?;
1011 generator.add_icons(&[(identifier3, test_icon.clone())], &HashMap::new())?;
1012
1013 let content_after = fs::read_to_string(&mod_rs_path)?;
1015
1016 assert!(
1018 content_after.contains("mod heroicons;"),
1019 "Should preserve 'mod heroicons;' without pub"
1020 );
1021 assert!(
1022 content_after.contains("pub mod mdi;"),
1023 "Should preserve 'pub mod mdi;'"
1024 );
1025 assert!(
1026 content_after.contains("pub mod lucide;"),
1027 "New modules should be added with 'pub mod'"
1028 );
1029
1030 let has_heroicons = content_after.lines().any(|l| l.trim() == "mod heroicons;");
1032 let has_mdi = content_after.lines().any(|l| l.trim() == "pub mod mdi;");
1033 let has_lucide = content_after.lines().any(|l| l.trim() == "pub mod lucide;");
1034
1035 assert!(has_heroicons, "Should have mod heroicons;");
1036 assert!(has_mdi, "Should have pub mod mdi;");
1037 assert!(has_lucide, "Should have pub mod lucide;");
1038
1039 Ok(())
1040 }
1041
1042 #[test]
1043 fn test_extract_module_declarations() {
1044 let content = r#"
1045// Some comment
1046pub mod mdi;
1047mod heroicons;
1048pub(crate) mod feather;
1049pub mod simple_icons;
1050"#;
1051
1052 let modules = extract_module_declarations(content);
1053
1054 assert_eq!(modules.len(), 4);
1055 assert_eq!(modules.get("mdi"), Some(&"pub ".to_string()));
1056 assert_eq!(modules.get("heroicons"), Some(&"".to_string()));
1057 assert_eq!(modules.get("feather"), Some(&"pub(crate) ".to_string()));
1058 assert_eq!(modules.get("simple_icons"), Some(&"pub ".to_string()));
1059 }
1060
1061 #[test]
1062 fn test_custom_user_module_preservation() -> Result<()> {
1063 let temp_dir = TempDir::new()?;
1064 let icons_dir = temp_dir.path().join("icons");
1065 let generator = Generator::new(icons_dir.clone());
1066
1067 fs::create_dir_all(&icons_dir)?;
1069 let app_rs_path = icons_dir.join("app.rs");
1070 let custom_icon_content = r##"/// Custom user-defined icons
1071use super::IconData;
1072
1073#[allow(non_upper_case_globals)]
1074pub const CustomLogo: IconData = IconData {
1075 name: "app:custom-logo",
1076 body: r#"<rect width="100" height="100" fill="blue"/>"#,
1077 view_box: "0 0 100 100",
1078 width: "100",
1079 height: "100",
1080};
1081
1082#[allow(non_upper_case_globals)]
1083pub const CustomBrand: IconData = IconData {
1084 name: "app:custom-brand",
1085 body: r#"<circle cx="50" cy="50" r="40" fill="red"/>"#,
1086 view_box: "0 0 100 100",
1087 width: "100",
1088 height: "100",
1089};
1090"##;
1091 fs::write(&app_rs_path, custom_icon_content)?;
1092
1093 generator.init()?;
1095 let mod_rs_path = icons_dir.join("mod.rs");
1096 let initial_mod_content = format!(
1097 "{}
1098pub(crate) mod app;
1099",
1100 MOD_RS_TEMPLATE
1101 );
1102 fs::write(&mod_rs_path, initial_mod_content)?;
1103
1104 assert!(app_rs_path.exists(), "Custom app.rs should exist");
1106 let custom_content_before = fs::read_to_string(&app_rs_path)?;
1107 assert!(
1108 custom_content_before.contains("CustomLogo"),
1109 "Custom icon should be defined"
1110 );
1111
1112 let test_icon = IconifyIcon {
1114 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
1115 width: Some(24),
1116 height: Some(24),
1117 view_box: Some("0 0 24 24".to_string()),
1118 };
1119
1120 let identifier1 = IconIdentifier::parse("mdi:home")?;
1121 let identifier2 = IconIdentifier::parse("heroicons:star")?;
1122
1123 generator.add_icons(
1124 &[
1125 (identifier1, test_icon.clone()),
1126 (identifier2, test_icon.clone()),
1127 ],
1128 &HashMap::new(),
1129 )?;
1130
1131 assert!(
1133 app_rs_path.exists(),
1134 "Custom app.rs should still exist after adding generated icons"
1135 );
1136 let custom_content_after = fs::read_to_string(&app_rs_path)?;
1137 assert_eq!(
1138 custom_content_before, custom_content_after,
1139 "Custom module content should not be modified"
1140 );
1141
1142 let mod_rs_content = fs::read_to_string(&mod_rs_path)?;
1144
1145 assert!(
1147 mod_rs_content.contains("pub(crate) mod app;"),
1148 "Custom module should be preserved with pub(crate) visibility"
1149 );
1150
1151 assert!(
1153 mod_rs_content.contains("pub mod mdi;"),
1154 "Generated mdi module should be added"
1155 );
1156 assert!(
1157 mod_rs_content.contains("pub mod heroicons;"),
1158 "Generated heroicons module should be added"
1159 );
1160
1161 let module_count = mod_rs_content
1163 .lines()
1164 .filter(|line| line.trim().contains("mod ") && line.trim().ends_with(';'))
1165 .count();
1166 assert_eq!(
1167 module_count, 3,
1168 "Should have exactly 3 modules: app, heroicons, and mdi"
1169 );
1170
1171 let mod_lines: Vec<&str> = mod_rs_content
1173 .lines()
1174 .filter(|line| line.trim().contains("mod ") && line.trim().ends_with(';'))
1175 .collect();
1176 assert_eq!(mod_lines.len(), 3, "Should have 3 module lines");
1177
1178 let mut module_names: Vec<String> = Vec::new();
1180 for line in &mod_lines {
1181 if let Some(name_start) = line.find("mod ") {
1182 let name_part = &line[name_start + 4..];
1183 if let Some(name_end) = name_part.find(';') {
1184 module_names.push(name_part[..name_end].trim().to_string());
1185 }
1186 }
1187 }
1188 assert_eq!(module_names, vec!["app", "heroicons", "mdi"]);
1189
1190 Ok(())
1191 }
1192}