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 pub fn regenerate_mod_rs(&self) -> Result<()> {
209 let mod_rs_path = self.icons_dir.join("mod.rs");
210
211 if !mod_rs_path.exists() {
213 return self.init();
214 }
215
216 let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
218
219 let mut existing_modules: HashSet<String> = HashSet::new();
221 for line in content.lines() {
222 if line.trim().starts_with("pub mod ")
223 && let Some(module_name) = line
224 .trim()
225 .strip_prefix("pub mod ")
226 .and_then(|s| s.strip_suffix(';'))
227 {
228 existing_modules.insert(module_name.trim().to_string());
229 }
230 }
231
232 let mut new_content = MOD_RS_TEMPLATE.to_string();
234 new_content.push('\n');
235
236 let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
238 sorted_modules.sort();
239
240 for module in sorted_modules {
241 new_content.push_str(&format!("pub mod {};\n", module));
242 }
243
244 fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
245
246 Ok(())
247 }
248
249 fn update_collection_file(
251 &self,
252 collection: &str,
253 new_icons: &[(IconIdentifier, IconifyIcon)],
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 = self.generate_collection_file(collection, &existing_icons)?;
272
273 fs::write(&file_path, content)
275 .context(format!("Failed to write collection file {:?}", file_path))?;
276
277 println!(
278 "✓ Updated {}.rs with {} icon(s)",
279 module_name,
280 new_icons.len()
281 );
282
283 Ok(())
284 }
285
286 fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
288 let content =
289 fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
290
291 let mut icons = BTreeMap::new();
292
293 let lines: Vec<&str> = content.lines().collect();
296 let mut i = 0;
297
298 while i < lines.len() {
299 let line = lines[i].trim();
300
301 if line.starts_with("pub const ")
303 && line.contains(": IconData")
304 && let Some(name_end) = line.find(':')
305 {
306 let name = line[10..name_end].trim().to_string();
307
308 if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
310 icons.insert(name, icon_const);
311 }
312 }
313
314 i += 1;
315 }
316
317 Ok(icons)
318 }
319
320 fn parse_icon_data(
322 &self,
323 lines: &[&str],
324 index: &mut usize,
325 const_name: &str,
326 ) -> Option<IconConst> {
327 let mut full_icon_name = String::new();
328 let mut body = String::new();
329 let mut view_box = String::new();
330 let mut width = String::new();
331 let mut height = String::new();
332
333 let mut j = *index;
335 while j < lines.len() {
336 let line = lines[j].trim();
337
338 if line.contains("name:") {
339 full_icon_name = extract_string_value(line);
340 } else if line.contains("body:") {
341 body = extract_raw_string_value(lines, &mut j);
343 } else if line.contains("view_box:") {
344 view_box = extract_string_value(line);
345 } else if line.contains("width:") {
346 width = extract_string_value(line);
347 } else if line.contains("height:") {
348 height = extract_string_value(line);
349 }
350
351 if line.contains("};") {
353 break;
354 }
355
356 j += 1;
357 }
358
359 *index = j;
360
361 if !full_icon_name.is_empty() && !body.is_empty() {
362 Some(IconConst {
363 name: const_name.to_string(),
364 full_icon_name,
365 body,
366 view_box,
367 width,
368 height,
369 })
370 } else {
371 None
372 }
373 }
374
375 fn generate_collection_file(
377 &self,
378 collection: &str,
379 icons: &BTreeMap<String, IconConst>,
380 ) -> Result<String> {
381 let mut content = format!(
382 "// Auto-generated by dioxus-iconify - DO NOT EDIT\n// Collection: {}\n\nuse super::IconData;\n\n",
383 collection
384 );
385
386 for icon_const in icons.values() {
388 content.push_str(&icon_const.to_rust_code());
389 content.push_str("\n\n");
390 }
391
392 Ok(content)
393 }
394
395 fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
397 let mod_rs_path = self.icons_dir.join("mod.rs");
398
399 let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
401
402 let mut existing_modules: HashSet<String> = HashSet::new();
404 for line in content.lines() {
405 if line.trim().starts_with("pub mod ")
406 && let Some(module_name) = line
407 .trim()
408 .strip_prefix("pub mod ")
409 .and_then(|s| s.strip_suffix(';'))
410 {
411 existing_modules.insert(module_name.trim().to_string());
412 }
413 }
414
415 let mut needs_update = false;
417 for collection in collections {
418 let module_name = collection.replace('-', "_");
419 if !existing_modules.contains(&module_name) {
420 existing_modules.insert(module_name);
421 needs_update = true;
422 }
423 }
424
425 if needs_update {
427 let mut new_content = MOD_RS_TEMPLATE.to_string();
428 new_content.push('\n');
429
430 let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
432 sorted_modules.sort();
433
434 for module in sorted_modules {
435 new_content.push_str(&format!("pub mod {};\n", module));
436 }
437
438 fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
439 }
440
441 Ok(())
442 }
443}
444
445fn extract_string_value(line: &str) -> String {
447 if let Some(start) = line.find('"')
448 && let Some(end) = line.rfind('"')
449 && end > start
450 {
451 return line[start + 1..end].to_string();
452 }
453 String::new()
454}
455
456fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
458 let line = lines[*index];
459
460 if let Some(start) = line.find("r#\"") {
462 let start_pos = start + 3;
463
464 if let Some(end) = line[start_pos..].find("\"#") {
466 return line[start_pos..start_pos + end].to_string();
467 }
468
469 let mut result = line[start_pos..].to_string();
471 *index += 1;
472
473 while *index < lines.len() {
474 let next_line = lines[*index];
475 if let Some(end) = next_line.find("\"#") {
476 result.push_str(&next_line[..end]);
477 break;
478 }
479 result.push_str(next_line);
480 result.push('\n');
481 *index += 1;
482 }
483
484 result
485 } else {
486 String::new()
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use crate::api::IconifyIcon;
494 use tempfile::TempDir;
495
496 #[test]
497 fn test_list_icons_empty_directory() -> Result<()> {
498 let temp_dir = TempDir::new()?;
499 let generator = Generator::new(temp_dir.path().join("icons"));
500
501 let icons = generator.list_icons()?;
502 assert!(
503 icons.is_empty(),
504 "Should return empty map for non-existent directory"
505 );
506
507 Ok(())
508 }
509
510 #[test]
511 fn test_list_icons_with_generated_icons() -> Result<()> {
512 let temp_dir = TempDir::new()?;
513 let icons_dir = temp_dir.path().join("icons");
514 let generator = Generator::new(icons_dir.clone());
515
516 let test_icon1 = IconifyIcon {
518 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
519 width: Some(24),
520 height: Some(24),
521 view_box: Some("0 0 24 24".to_string()),
522 };
523
524 let test_icon2 = IconifyIcon {
525 body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
526 width: Some(24),
527 height: Some(24),
528 view_box: Some("0 0 24 24".to_string()),
529 };
530
531 let identifier1 = IconIdentifier::parse("mdi:home")?;
532 let identifier2 = IconIdentifier::parse("mdi:settings")?;
533 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
534
535 generator.add_icons(&[
536 (identifier1, test_icon1.clone()),
537 (identifier2, test_icon2.clone()),
538 (identifier3, test_icon1.clone()),
539 ])?;
540
541 let icons = generator.list_icons()?;
543
544 assert_eq!(icons.len(), 2, "Should have 2 collections");
546 assert!(icons.contains_key("mdi"), "Should have mdi collection");
547 assert!(
548 icons.contains_key("heroicons"),
549 "Should have heroicons collection"
550 );
551
552 let mdi_icons = icons.get("mdi").unwrap();
554 assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
555 assert!(mdi_icons.contains(&"mdi:home".to_string()));
556 assert!(mdi_icons.contains(&"mdi:settings".to_string()));
557
558 let heroicons_icons = icons.get("heroicons").unwrap();
560 assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
561 assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
562
563 Ok(())
564 }
565
566 #[test]
567 fn test_get_all_icon_identifiers() -> Result<()> {
568 let temp_dir = TempDir::new()?;
569 let icons_dir = temp_dir.path().join("icons");
570 let generator = Generator::new(icons_dir.clone());
571
572 let empty_icons = generator.get_all_icon_identifiers()?;
574 assert!(
575 empty_icons.is_empty(),
576 "Should return empty vec for no icons"
577 );
578
579 let test_icon = IconifyIcon {
581 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
582 width: Some(24),
583 height: Some(24),
584 view_box: Some("0 0 24 24".to_string()),
585 };
586
587 let identifier1 = IconIdentifier::parse("mdi:home")?;
588 let identifier2 = IconIdentifier::parse("mdi:settings")?;
589 let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
590
591 generator.add_icons(&[
592 (identifier1, test_icon.clone()),
593 (identifier2, test_icon.clone()),
594 (identifier3, test_icon.clone()),
595 ])?;
596
597 let all_icons = generator.get_all_icon_identifiers()?;
599
600 assert_eq!(all_icons.len(), 3, "Should have 3 icons");
602 assert!(
603 all_icons.contains(&"mdi:home".to_string()),
604 "Should contain mdi:home"
605 );
606 assert!(
607 all_icons.contains(&"mdi:settings".to_string()),
608 "Should contain mdi:settings"
609 );
610 assert!(
611 all_icons.contains(&"heroicons:arrow-left".to_string()),
612 "Should contain heroicons:arrow-left"
613 );
614
615 Ok(())
616 }
617
618 #[test]
619 fn test_regenerate_mod_rs_updates_template() -> Result<()> {
620 let temp_dir = TempDir::new()?;
621 let icons_dir = temp_dir.path().join("icons");
622 let generator = Generator::new(icons_dir.clone());
623
624 let test_icon = IconifyIcon {
626 body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
627 width: Some(24),
628 height: Some(24),
629 view_box: Some("0 0 24 24".to_string()),
630 };
631
632 let identifier1 = IconIdentifier::parse("mdi:home")?;
633 let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
634
635 generator.add_icons(&[
636 (identifier1, test_icon.clone()),
637 (identifier2, test_icon.clone()),
638 ])?;
639
640 let mod_rs_path = icons_dir.join("mod.rs");
641 assert!(mod_rs_path.exists(), "mod.rs should exist");
642
643 let old_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
645use dioxus::prelude::*;
646
647// OLD VERSION WITHOUT SIZE PARAMETER
648#[component]
649pub fn Icon(data: IconData) -> Element {
650 rsx! { svg {} }
651}
652
653pub mod heroicons;
654pub mod mdi;
655"#;
656 fs::write(&mod_rs_path, old_content)?;
657
658 let content_before = fs::read_to_string(&mod_rs_path)?;
660 assert!(
661 content_before.contains("OLD VERSION"),
662 "Should have old version marker"
663 );
664 assert!(
665 !content_before.contains("size: Option<String>"),
666 "Should not have size parameter yet"
667 );
668
669 generator.regenerate_mod_rs()?;
671
672 let content_after = fs::read_to_string(&mod_rs_path)?;
674 assert!(
675 !content_after.contains("OLD VERSION"),
676 "Should not have old version marker"
677 );
678 assert!(
679 content_after.contains("size: Option<String>"),
680 "Should have size parameter from latest template"
681 );
682 assert!(
683 content_after.contains("pub mod heroicons;"),
684 "Should preserve heroicons module"
685 );
686 assert!(
687 content_after.contains("pub mod mdi;"),
688 "Should preserve mdi module"
689 );
690
691 Ok(())
692 }
693}