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